diff --git a/CHANGELOG.md b/CHANGELOG.md index 504d70765..a481ee66b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Full compatibility with Erlang OTP26 - handling of GET /api/v1/preferences - Akkoma API is now documented +- ability to auto-approve follow requests from users you are already following ## Changed - OTP builds are now built on erlang OTP26 diff --git a/docs/docs/development/API/differences_in_mastoapi_responses.md b/docs/docs/development/API/differences_in_mastoapi_responses.md index 8451558ff..990806cea 100644 --- a/docs/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/docs/development/API/differences_in_mastoapi_responses.md @@ -121,6 +121,12 @@ Has these additional fields under the `pleroma` object: - `notification_settings`: object, can be absent. See `/api/v1/pleroma/notification_settings` for the parameters/keys returned. - `favicon`: nullable URL string, Favicon image of the user's instance +Has these additional fields under the `akkoma` object: + +- `instance`: nullable object with metadata about the user’s instance +- `status_ttl_days`: nullable int, default time after which statuses are deleted +- `permit_followback`: boolean, whether follows from followed accounts are auto-approved + ### Source Has these additional fields under the `pleroma` object: diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 1b78d0479..35f416e6c 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -160,6 +160,7 @@ defmodule Pleroma.User do field(:last_status_at, :naive_datetime) field(:language, :string) field(:status_ttl_days, :integer, default: nil) + field(:permit_followback, :boolean, default: false) field(:accepts_direct_messages_from, Ecto.Enum, values: [:everybody, :people_i_follow, :nobody], @@ -544,6 +545,7 @@ def update_changeset(struct, params \\ %{}) do :actor_type, :disclose_client, :status_ttl_days, + :permit_followback, :accepts_direct_messages_from ] ) @@ -972,16 +974,21 @@ def needs_update?(%User{local: false} = user) do def needs_update?(_), do: true + # "Locked" (self-locked) users demand explicit authorization of follow requests + @spec can_direct_follow_local(User.t(), User.t()) :: true | false + def can_direct_follow_local(%User{} = follower, %User{local: true} = followed) do + !followed.is_locked || (followed.permit_followback and is_friend_of(follower, followed)) + end + @spec maybe_direct_follow(User.t(), User.t()) :: {:ok, User.t(), User.t()} | {:error, String.t()} - # "Locked" (self-locked) users demand explicit authorization of follow requests - def maybe_direct_follow(%User{} = follower, %User{local: true, is_locked: true} = followed) do - follow(follower, followed, :follow_pending) - end - def maybe_direct_follow(%User{} = follower, %User{local: true} = followed) do - follow(follower, followed) + if can_direct_follow_local(follower, followed) do + follow(follower, followed) + else + follow(follower, followed, :follow_pending) + end end def maybe_direct_follow(%User{} = follower, %User{} = followed) do @@ -1331,6 +1338,13 @@ def get_friends_ids(%User{} = user, page \\ nil) do |> Repo.all() end + def is_friend_of(%User{} = potential_friend, %User{local: true} = user) do + user + |> get_friends_query() + |> where(id: ^potential_friend.id) + |> Repo.exists?() + end + def increase_note_count(%User{} = user) do User |> where(id: ^user.id) diff --git a/lib/pleroma/web/activity_pub/side_effects.ex b/lib/pleroma/web/activity_pub/side_effects.ex index 34617a218..963c6d8c6 100644 --- a/lib/pleroma/web/activity_pub/side_effects.ex +++ b/lib/pleroma/web/activity_pub/side_effects.ex @@ -109,7 +109,7 @@ def handle( %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.is_locked do + if followed.local && User.can_direct_follow_local(follower, followed) do {:ok, accept_data, _} = Builder.accept(followed, object) {:ok, _activity, _} = Pipeline.common_pipeline(accept_data, local: true) end diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index 9a6dfc1ff..c15a246f2 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -723,6 +723,12 @@ defp update_credentials_request do description: "Number of days after which statuses will be deleted. Set to -1 to disable." }, + permit_followback: %Schema{ + allOf: [BooleanLike], + nullable: true, + description: + "Whether follow requests from accounts the user is already following are auto-approved (when locked)." + }, accepts_direct_messages_from: %Schema{ type: :string, enum: [ @@ -754,6 +760,7 @@ defp update_credentials_request do discoverable: false, actor_type: "Person", status_ttl_days: 30, + permit_followback: true, accepts_direct_messages_from: "everybody" } } diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 2693eaceb..a922f6d1b 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -112,7 +112,18 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do akkoma: %Schema{ type: :object, properties: %{ - note_ttl_days: %Schema{type: :integer} + instance: %Schema{ + type: :object, + nullable: true, + properties: %{ + name: %Schema{type: :string}, + favicon: %Schema{type: :string, format: :uri, nullable: true}, + # XXX: proper nodeinfo schema + nodeinfo: %Schema{type: :object, nullable: true} + } + }, + status_ttl_days: %Schema{type: :integer, nullable: true}, + permit_followback: %Schema{type: :boolean} } }, source: %Schema{ @@ -205,6 +216,18 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do "pleroma-fe" => %{} } }, + "akkoma" => %{ + "instance" => %{ + "name" => "ihatebeinga.live", + "favicon" => "https://ihatebeinga.live/favicon.png", + "nodeinfo" => + %{ + # XXX: nodeinfo schema + } + }, + "status_ttl_days" => nil, + "permit_followback" => true + }, "source" => %{ "fields" => [], "note" => "foobar", diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 6fc88354f..2c7d6a893 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -222,6 +222,7 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p |> Maps.put_if_present(:language, Pleroma.Web.Gettext.normalize_locale(params[:language])) |> Maps.put_if_present(:status_ttl_days, params[:status_ttl_days], status_ttl_days_value) |> Maps.put_if_present(:accepts_direct_messages_from, params[:accepts_direct_messages_from]) + |> Maps.put_if_present(:permit_followback, params[:permit_followback]) # What happens here: # diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 2f543c08a..1cb025bd0 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -292,7 +292,8 @@ defp do_render("show.json", %{user: user} = opts) do last_status_at: user.last_status_at, akkoma: %{ instance: render("instance.json", %{instance: instance}), - status_ttl_days: user.status_ttl_days + status_ttl_days: user.status_ttl_days, + permit_followback: user.permit_followback }, # Pleroma extensions # Note: it's insecure to output :email but fully-qualified nickname may serve as safe stub diff --git a/priv/repo/migrations/20240213120000_add_permit_followback.exs b/priv/repo/migrations/20240213120000_add_permit_followback.exs new file mode 100644 index 000000000..72475a58e --- /dev/null +++ b/priv/repo/migrations/20240213120000_add_permit_followback.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddPermitFollowback do + use Ecto.Migration + + def change do + alter table(:users) do + add(:permit_followback, :boolean, null: false, default: false) + end + end +end diff --git a/test/pleroma/web/common_api_test.exs b/test/pleroma/web/common_api_test.exs index f2675ad01..379cf85b8 100644 --- a/test/pleroma/web/common_api_test.exs +++ b/test/pleroma/web/common_api_test.exs @@ -1061,6 +1061,36 @@ test "directly follows a non-locked local user" do assert User.following?(follower, followed) end + + test "directly follows back a locked, but followback-allowing local user" do + uopen = insert(:user, is_locked: false) + uselective = insert(:user, is_locked: true, permit_followback: true) + + assert {:ok, uselective, uopen, %{data: %{"state" => "accept"}}} = + CommonAPI.follow(uselective, uopen) + + assert User.get_follow_state(uselective, uopen) == :follow_accept + + assert {:ok, uopen, uselective, %{data: %{"state" => "accept"}}} = + CommonAPI.follow(uopen, uselective) + + assert User.get_follow_state(uopen, uselective) == :follow_accept + end + + test "creates a pending request for locked, non-followback local user" do + uopen = insert(:user, is_locked: false) + ulocked = insert(:user, is_locked: true, permit_followback: false) + + assert {:ok, ulocked, uopen, %{data: %{"state" => "accept"}}} = + CommonAPI.follow(ulocked, uopen) + + assert User.get_follow_state(ulocked, uopen) == :follow_accept + + assert {:ok, uopen, ulocked, %{data: %{"state" => "pending"}}} = + CommonAPI.follow(uopen, ulocked) + + assert User.get_follow_state(uopen, ulocked) == :follow_pending + end end describe "unfollow/2" do diff --git a/test/pleroma/web/mastodon_api/views/account_view_test.exs b/test/pleroma/web/mastodon_api/views/account_view_test.exs index 5b8aea8ac..b05759d54 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -65,7 +65,8 @@ test "Represent a user account" do }, favicon: nil }, - status_ttl_days: 5 + status_ttl_days: 5, + permit_followback: false }, avatar: "http://localhost:4001/images/avi.png", avatar_static: "http://localhost:4001/images/avi.png", @@ -248,7 +249,8 @@ test "Represent a Service(bot) account" do favicon: "http://localhost:4001/favicon.png", nodeinfo: %{version: "2.0"} }, - status_ttl_days: nil + status_ttl_days: nil, + permit_followback: false }, pleroma: %{ ap_id: user.ap_id,