diff --git a/CHANGELOG.md b/CHANGELOG.md index dadcc5e3f..3c8eae1f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,22 @@ 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 +## 2023.05 + +## Added +- Custom options for users to accept/reject private messages + - options: everybody, nobody, people\_i\_follow +- MRF to reject notes from accounts newer than a given age + - this will have the side-effect of rejecting legitimate messages if your + post gets boosted outside of your local bubble and people your instance + does not know about reply to it. ## Fixed - Support for `streams` public key URIs +- Bookmarks are cleaned up on DB prune now + +## Security +- Fixed mediaproxy being a bit of a silly billy ## 2023.04 diff --git a/config/config.exs b/config/config.exs index ee56d63fb..cf4f09087 100644 --- a/config/config.exs +++ b/config/config.exs @@ -429,6 +429,8 @@ config :pleroma, :mrf_follow_bot, follower_nickname: nil +config :pleroma, :mrf_reject_newly_created_account_notes, age: 86_400 + config :pleroma, :rich_media, enabled: true, ignore_hosts: [], diff --git a/lib/pleroma/akkoma/translators/libre_translate.ex b/lib/pleroma/akkoma/translators/libre_translate.ex index 5b08a6384..80956ab66 100644 --- a/lib/pleroma/akkoma/translators/libre_translate.ex +++ b/lib/pleroma/akkoma/translators/libre_translate.ex @@ -39,9 +39,9 @@ def translate(string, from_language, to_language) do detected = if Map.has_key?(body, "detectedLanguage") do get_in(body, ["detectedLanguage", "language"]) - else + else from_language || "" - end + end {:ok, detected, translated} else diff --git a/lib/pleroma/reverse_proxy.ex b/lib/pleroma/reverse_proxy.ex index 91cf1bba3..b44f0b90a 100644 --- a/lib/pleroma/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy.ex @@ -251,6 +251,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) + |> build_csp_headers() |> Keyword.merge(Keyword.get(opts, :resp_headers, [])) end @@ -316,6 +317,10 @@ defp build_resp_content_disposition_header(headers, opts) do end end + defp build_csp_headers(headers) do + List.keystore(headers, "content-security-policy", 0, {"content-security-policy", "sandbox"}) + end + defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do with {_, size} <- List.keyfind(headers, "content-length", 0), {size, _} <- Integer.parse(size), diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 2cf8949d4..f6c3b397a 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -160,6 +160,11 @@ defmodule Pleroma.User do field(:language, :string) field(:status_ttl_days, :integer, default: nil) + field(:accepts_direct_messages_from, Ecto.Enum, + values: [:everybody, :people_i_follow, :nobody], + default: :everybody + ) + embeds_one( :notification_settings, Pleroma.User.NotificationSetting, @@ -550,7 +555,8 @@ def update_changeset(struct, params \\ %{}) do :actor_type, :accepts_chat_messages, :disclose_client, - :status_ttl_days + :status_ttl_days, + :accepts_direct_messages_from ] ) |> unique_constraint(:nickname) @@ -2748,4 +2754,16 @@ def unfollow_hashtag(%User{} = user, %Hashtag{} = hashtag) do def following_hashtag?(%User{} = user, %Hashtag{} = hashtag) do not is_nil(HashtagFollow.get(user, hashtag)) end + + def accepts_direct_messages?( + %User{accepts_direct_messages_from: :people_i_follow} = receiver, + %User{} = sender + ) do + User.following?(receiver, sender) + end + + def accepts_direct_messages?(%User{accepts_direct_messages_from: :everybody}, _), do: true + + def accepts_direct_messages?(%User{accepts_direct_messages_from: :nobody}, _), + do: false end diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index 6f79ffbf0..c413cf6fc 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -141,7 +141,8 @@ def get_policies do |> Enum.concat([ Pleroma.Web.ActivityPub.MRF.HashtagPolicy, Pleroma.Web.ActivityPub.MRF.InlineQuotePolicy, - Pleroma.Web.ActivityPub.MRF.NormalizeMarkup + Pleroma.Web.ActivityPub.MRF.NormalizeMarkup, + Pleroma.Web.ActivityPub.MRF.DirectMessageDisabledPolicy ]) |> Enum.uniq() end diff --git a/lib/pleroma/web/activity_pub/mrf/direct_message_disabled_policy.ex b/lib/pleroma/web/activity_pub/mrf/direct_message_disabled_policy.ex new file mode 100644 index 000000000..7a834f6ae --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/direct_message_disabled_policy.ex @@ -0,0 +1,65 @@ +defmodule Pleroma.Web.ActivityPub.MRF.DirectMessageDisabledPolicy do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.User + require Pleroma.Constants + + @moduledoc """ + Removes entries from the "To" field from direct messages if the user has requested to not + allow direct messages + """ + + @impl true + def filter( + %{ + "type" => "Create", + "actor" => actor, + "object" => %{ + "type" => "Note" + } + } = activity + ) do + with recipients <- Map.get(activity, "to", []), + cc <- Map.get(activity, "cc", []), + true <- is_direct?(recipients, cc), + sender <- User.get_cached_by_ap_id(actor) do + new_to = + Enum.filter(recipients, fn recv -> + should_include?(sender, recv) + end) + + {:ok, + activity + |> Map.put("to", new_to) + |> maybe_replace_object_to(new_to)} + else + _ -> + {:ok, activity} + end + end + + @impl true + def filter(object), do: {:ok, object} + + @impl true + def describe, do: {:ok, %{}} + + defp should_include?(sender, receiver_ap_id) do + with %User{local: true} = receiver <- User.get_cached_by_ap_id(receiver_ap_id) do + User.accepts_direct_messages?(receiver, sender) + else + _ -> true + end + end + + defp maybe_replace_object_to(%{"object" => %{"to" => _}} = activity, to) do + Kernel.put_in(activity, ["object", "to"], to) + end + + defp maybe_replace_object_to(other, _), do: other + + defp is_direct?(to, cc) do + !(Enum.member?(to, Pleroma.Constants.as_public()) || + Enum.member?(cc, Pleroma.Constants.as_public())) + end +end diff --git a/lib/pleroma/web/activity_pub/mrf/reject_newly_created_account_note_policy.ex b/lib/pleroma/web/activity_pub/mrf/reject_newly_created_account_note_policy.ex new file mode 100644 index 000000000..01a846831 --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/reject_newly_created_account_note_policy.ex @@ -0,0 +1,50 @@ +defmodule Pleroma.Web.ActivityPub.MRF.RejectNewlyCreatedAccountNotesPolicy do + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.User + + @moduledoc """ + Rejects notes from accounts that were created below a certain threshold of time ago + """ + @impl true + def filter( + %{ + "type" => type, + "actor" => actor + } = activity + ) + when type in ["Note", "Create"] do + min_age = Pleroma.Config.get([:mrf_reject_newly_created_account_notes, :age]) + + with %User{local: false} = user <- Pleroma.User.get_cached_by_ap_id(actor), + true <- Timex.diff(Timex.now(), user.inserted_at, :seconds) < min_age do + {:reject, "[RejectNewlyCreatedAccountNotesPolicy] Account created too recently"} + else + _ -> {:ok, activity} + end + end + + @impl true + def filter(object), do: {:ok, object} + + @impl true + def describe, do: {:ok, %{}} + + @impl true + def config_description do + %{ + key: :mrf_reject_newly_created_account_notes, + related_policy: "Pleroma.Web.ActivityPub.MRF.RejectNewlyCreatedAccountNotesPolicy", + label: "MRF Reject New Accounts", + description: "Reject notes from accounts created too recently", + children: [ + %{ + key: :age, + type: :integer, + description: "Time below which to reject (in seconds)", + suggestions: [86_400] + } + ] + } + 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 d156c58e7..96c16d802 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -713,6 +713,16 @@ defp update_credentials_request do nullable: true, description: "Number of days after which statuses will be deleted. Set to -1 to disable." + }, + accepts_direct_messages_from: %Schema{ + type: :string, + enum: [ + "everybody", + "nobody", + "people_i_follow" + ], + nullable: true, + description: "Who to accept DMs from" } }, example: %{ @@ -734,7 +744,8 @@ defp update_credentials_request do also_known_as: ["https://foo.bar/users/foo"], discoverable: false, actor_type: "Person", - status_ttl_days: 30 + status_ttl_days: 30, + accepts_direct_messages_from: "everybody" } } end diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index bb473c348..4139aaf06 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -225,6 +225,7 @@ def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _p |> Maps.put_if_present(:is_discoverable, params[:discoverable]) |> 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]) IO.inspect(user_params) # 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 6073ffd29..d17131350 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -356,6 +356,7 @@ defp maybe_put_settings( |> Kernel.put_in([:source, :privacy], user.default_scope) |> Kernel.put_in([:source, :pleroma, :show_role], user.show_role) |> Kernel.put_in([:source, :pleroma, :no_rich_text], user.no_rich_text) + |> Kernel.put_in([:accepts_direct_messages_from], user.accepts_direct_messages_from) end defp maybe_put_settings(data, _, _, _), do: data diff --git a/mix.exs b/mix.exs index 4833f90a8..da02fe53a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do def project do [ app: :pleroma, - version: version("3.8.0"), + version: version("3.9.2"), elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix] ++ Mix.compilers(), diff --git a/priv/repo/migrations/20230522213837_add_unfollowed_dm_restrictions.exs b/priv/repo/migrations/20230522213837_add_unfollowed_dm_restrictions.exs new file mode 100644 index 000000000..8947be738 --- /dev/null +++ b/priv/repo/migrations/20230522213837_add_unfollowed_dm_restrictions.exs @@ -0,0 +1,9 @@ +defmodule Pleroma.Repo.Migrations.AddUnfollowedDmRestrictions do + use Ecto.Migration + + def change do + alter table(:users) do + add(:accepts_direct_messages_from, :string, default: "everybody") + end + end +end diff --git a/test/pleroma/translators/libre_translate_test.exs b/test/pleroma/translators/libre_translate_test.exs index a93f408f5..2ba75ec0e 100644 --- a/test/pleroma/translators/libre_translate_test.exs +++ b/test/pleroma/translators/libre_translate_test.exs @@ -146,8 +146,7 @@ test "should work when no detected language is received" do } end) - assert {:ok, "", "I will crush you"} = - LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", nil, "en") + assert {:ok, "", "I will crush you"} = LibreTranslate.translate("ギュギュ握りつぶしちゃうぞ", nil, "en") end end end diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index f17646227..c30865130 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -2799,4 +2799,35 @@ test "should not error when trying to unfollow a hashtag twice" do assert user.followed_hashtags |> Enum.count() == 0 end end + + describe "accepts_direct_messages?/2" do + test "should return true if the recipient follows the sender and has set accept to :people_i_follow" do + recipient = + insert(:user, %{ + accepts_direct_messages_from: :people_i_follow + }) + + sender = insert(:user) + + refute User.accepts_direct_messages?(recipient, sender) + + CommonAPI.follow(recipient, sender) + + assert User.accepts_direct_messages?(recipient, sender) + end + + test "should return true if the recipient has set accept to :everyone" do + recipient = insert(:user, %{accepts_direct_messages_from: :everybody}) + sender = insert(:user) + + assert User.accepts_direct_messages?(recipient, sender) + end + + test "should return false if the receipient set accept to :nobody" do + recipient = insert(:user, %{accepts_direct_messages_from: :nobody}) + sender = insert(:user) + + refute User.accepts_direct_messages?(recipient, sender) + end + end end diff --git a/test/pleroma/web/activity_pub/mrf/direct_message_disabled_policy_test.exs b/test/pleroma/web/activity_pub/mrf/direct_message_disabled_policy_test.exs new file mode 100644 index 000000000..02ae24a4d --- /dev/null +++ b/test/pleroma/web/activity_pub/mrf/direct_message_disabled_policy_test.exs @@ -0,0 +1,52 @@ +defmodule Pleroma.Web.ActivityPub.MRF.DirectMessageDisabledPolicyTest do + use Pleroma.DataCase + import Pleroma.Factory + + alias Pleroma.Web.ActivityPub.MRF.DirectMessageDisabledPolicy + alias Pleroma.User + + describe "strips recipients" do + test "when the user denies the direct message" do + sender = insert(:user) + recipient = insert(:user, %{accepts_direct_messages_from: :nobody}) + + refute User.accepts_direct_messages?(recipient, sender) + + message = %{ + "actor" => sender.ap_id, + "to" => [recipient.ap_id], + "cc" => [], + "type" => "Create", + "object" => %{ + "type" => "Note", + "to" => [recipient.ap_id] + } + } + + assert {:ok, %{"to" => [], "object" => %{"to" => []}}} = + DirectMessageDisabledPolicy.filter(message) + end + + test "when the user does not deny the direct message" do + sender = insert(:user) + recipient = insert(:user, %{accepts_direct_messages_from: :everybody}) + + assert User.accepts_direct_messages?(recipient, sender) + + message = %{ + "actor" => sender.ap_id, + "to" => [recipient.ap_id], + "cc" => [], + "type" => "Create", + "object" => %{ + "type" => "Note", + "to" => [recipient.ap_id] + } + } + + assert {:ok, message} = DirectMessageDisabledPolicy.filter(message) + assert message["to"] == [recipient.ap_id] + assert message["object"]["to"] == [recipient.ap_id] + end + end +end diff --git a/test/pleroma/web/activity_pub/mrf/reject_newly_created_account_note_policy_test.exs b/test/pleroma/web/activity_pub/mrf/reject_newly_created_account_note_policy_test.exs new file mode 100644 index 000000000..2fc65e6d6 --- /dev/null +++ b/test/pleroma/web/activity_pub/mrf/reject_newly_created_account_note_policy_test.exs @@ -0,0 +1,45 @@ +defmodule Pleroma.Web.ActivityPub.MRF.RejectNewlyCreatedAccountNotesPolicyTest do + use Pleroma.DataCase + import Pleroma.Factory + + alias Pleroma.Web.ActivityPub.MRF.RejectNewlyCreatedAccountNotesPolicy + + describe "reject notes from new accounts" do + test "rejects notes from accounts created more recently than `age`" do + clear_config([:mrf_reject_newly_created_account_notes, :age], 86_400) + sender = insert(:user, %{inserted_at: Timex.now(), local: false}) + + message = %{ + "actor" => sender.ap_id, + "type" => "Create" + } + + assert {:reject, _} = RejectNewlyCreatedAccountNotesPolicy.filter(message) + end + + test "does not reject notes from accounts created longer ago" do + clear_config([:mrf_reject_newly_created_account_notes, :age], 86_400) + a_day_ago = Timex.shift(Timex.now(), days: -1) + sender = insert(:user, %{inserted_at: a_day_ago, local: false}) + + message = %{ + "actor" => sender.ap_id, + "type" => "Create" + } + + assert {:ok, _} = RejectNewlyCreatedAccountNotesPolicy.filter(message) + end + + test "does not affect local users" do + clear_config([:mrf_reject_newly_created_account_notes, :age], 86_400) + sender = insert(:user, %{inserted_at: Timex.now(), local: true}) + + message = %{ + "actor" => sender.ap_id, + "type" => "Create" + } + + assert {:ok, _} = RejectNewlyCreatedAccountNotesPolicy.filter(message) + end + end +end diff --git a/test/pleroma/web/activity_pub/mrf_test.exs b/test/pleroma/web/activity_pub/mrf_test.exs index 7359398fe..51af672cd 100644 --- a/test/pleroma/web/activity_pub/mrf_test.exs +++ b/test/pleroma/web/activity_pub/mrf_test.exs @@ -102,7 +102,13 @@ test "it works as expected with noop policy" do clear_config([:mrf, :policies], [Pleroma.Web.ActivityPub.MRF.NoOpPolicy]) expected = %{ - mrf_policies: ["NoOpPolicy", "HashtagPolicy", "InlineQuotePolicy", "NormalizeMarkup"], + mrf_policies: [ + "NoOpPolicy", + "HashtagPolicy", + "InlineQuotePolicy", + "NormalizeMarkup", + "DirectMessageDisabledPolicy" + ], mrf_hashtag: %{ federated_timeline_removal: [], reject: [], @@ -118,7 +124,13 @@ test "it works as expected with mock policy" do clear_config([:mrf, :policies], [MRFModuleMock]) expected = %{ - mrf_policies: ["MRFModuleMock", "HashtagPolicy", "InlineQuotePolicy", "NormalizeMarkup"], + mrf_policies: [ + "MRFModuleMock", + "HashtagPolicy", + "InlineQuotePolicy", + "NormalizeMarkup", + "DirectMessageDisabledPolicy" + ], mrf_module_mock: "some config data", mrf_hashtag: %{ federated_timeline_removal: [], diff --git a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs index 002042802..9faee7aa3 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/note_handling_test.exs @@ -316,7 +316,7 @@ test "it strips internal reactions" do test "it correctly processes messages with non-array to field" do data = File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("to", "https://www.w3.org/ns/activitystreams#Public") |> put_in(["object", "to"], "https://www.w3.org/ns/activitystreams#Public") @@ -333,7 +333,7 @@ test "it correctly processes messages with non-array to field" do test "it correctly processes messages with non-array cc field" do data = File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("cc", "http://mastodon.example.org/users/admin/followers") |> put_in(["object", "cc"], "http://mastodon.example.org/users/admin/followers") @@ -346,7 +346,7 @@ test "it correctly processes messages with non-array cc field" do test "it correctly processes messages with weirdness in address fields" do data = File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Map.put("cc", ["http://mastodon.example.org/users/admin/followers", ["¿"]]) |> put_in(["object", "cc"], ["http://mastodon.example.org/users/admin/followers", ["¿"]]) @@ -412,7 +412,7 @@ test "does NOT schedule background fetching of `replies` beyond max thread depth activity = File.read!("test/fixtures/mastodon-post-activity.json") - |> Poison.decode!() + |> Jason.decode!() |> Kernel.put_in(["object", "replies"], replies) %{activity: activity} diff --git a/test/pleroma/web/mastodon_api/update_credentials_test.exs b/test/pleroma/web/mastodon_api/update_credentials_test.exs index 1ceed0eee..342fa0068 100644 --- a/test/pleroma/web/mastodon_api/update_credentials_test.exs +++ b/test/pleroma/web/mastodon_api/update_credentials_test.exs @@ -734,4 +734,56 @@ test "actor_type field has a higher priority than bot", %{conn: conn} do assert account["source"]["pleroma"]["actor_type"] == "Person" end end + + describe "Updating direct message settings" do + setup do: oauth_access(["write:accounts"]) + setup :request_content_type + + test "changing to :everybody", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{ + accepts_direct_messages_from: "everybody" + }) + |> json_response_and_validate_schema(200) + + assert account["accepts_direct_messages_from"] + assert account["accepts_direct_messages_from"] == "everybody" + assert Pleroma.User.get_by_ap_id(account["url"]).accepts_direct_messages_from == :everybody + end + + test "changing to :nobody", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{accepts_direct_messages_from: "nobody"}) + |> json_response_and_validate_schema(200) + + assert account["accepts_direct_messages_from"] + assert account["accepts_direct_messages_from"] == "nobody" + assert Pleroma.User.get_by_ap_id(account["url"]).accepts_direct_messages_from == :nobody + end + + test "changing to :people_i_follow", %{conn: conn} do + account = + conn + |> patch("/api/v1/accounts/update_credentials", %{ + accepts_direct_messages_from: "people_i_follow" + }) + |> json_response_and_validate_schema(200) + + assert account["accepts_direct_messages_from"] + assert account["accepts_direct_messages_from"] == "people_i_follow" + + assert Pleroma.User.get_by_ap_id(account["url"]).accepts_direct_messages_from == + :people_i_follow + end + + test "changing to an unsupported value", %{conn: conn} do + conn + |> patch("/api/v1/accounts/update_credentials", %{ + accepts_direct_messages_from: "unsupported" + }) + |> json_response(400) + end + end end