From 3e4768efca88124b3ae418d41da923c428598275 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 4 Aug 2024 13:59:13 -0400 Subject: [PATCH 001/212] Revert "Remove invalid test" This reverts commit d0f4b2b02fc3aee3f08239d9c188ca5a2e8ad482. --- test/pleroma/integration/mastodon_websocket_test.exs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/pleroma/integration/mastodon_websocket_test.exs b/test/pleroma/integration/mastodon_websocket_test.exs index f499f54ad..88f32762d 100644 --- a/test/pleroma/integration/mastodon_websocket_test.exs +++ b/test/pleroma/integration/mastodon_websocket_test.exs @@ -268,6 +268,17 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do end) end + test "accepts valid token on Sec-WebSocket-Protocol header", %{token: token} do + assert {:ok, _} = start_socket("?stream=user", [{"Sec-WebSocket-Protocol", token.token}]) + + capture_log(fn -> + assert {:error, %WebSockex.RequestError{code: 401}} = + start_socket("?stream=user", [{"Sec-WebSocket-Protocol", "I am a friend"}]) + + Process.sleep(30) + end) + end + test "accepts valid token on client-sent event", %{token: token} do assert {:ok, pid} = start_socket() From 8c91fd8785c25e694d9341b17b5182041c575166 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 4 Aug 2024 14:58:16 -0400 Subject: [PATCH 002/212] Fix Mastodon WebSocket authentication Mastodon uses the Sec-Websocket-Protocol header to send the auth token. It is not clear if this is a violation of the RFC, but Mastodon is not the first application in the wild to use this header for authentication purposes. Phoenix does not allow accessing this header, so we work around it temporarily with a minor patch to Phoenix 1.7.14. We will reach out to Phoenix to discuss how to make this use case possible. --- changelog.d/mastodon-websocket.fix | 1 + lib/pleroma/web/endpoint.ex | 1 + lib/pleroma/web/mastodon_api/websocket_handler.ex | 11 ++++++++++- mix.exs | 3 ++- mix.lock | 4 ++-- 5 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 changelog.d/mastodon-websocket.fix diff --git a/changelog.d/mastodon-websocket.fix b/changelog.d/mastodon-websocket.fix new file mode 100644 index 000000000..2c4fe86ef --- /dev/null +++ b/changelog.d/mastodon-websocket.fix @@ -0,0 +1 @@ +Fix Mastodon WebSocket authentication diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index fef907ace..bab3c9fd0 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.Endpoint do websocket: [ path: "/", compress: false, + connect_info: [:sec_websocket_protocol], error_handler: {Pleroma.Web.MastodonAPI.WebsocketHandler, :handle_error, []}, fullsweep_after: 20 ] diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 730295a4c..3ed1cdd6c 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do # This only prepares the connection and is not in the process yet @impl Phoenix.Socket.Transport def connect(%{params: params} = transport_info) do - with access_token <- Map.get(params, "access_token"), + with access_token <- find_access_token(transport_info), {:ok, user, oauth_token} <- authenticate_request(access_token), {:ok, topic} <- Streamer.get_topic(params["stream"], user, oauth_token, params) do @@ -244,4 +244,13 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do def handle_error(conn, _reason) do Plug.Conn.send_resp(conn, 404, "Not Found") end + + defp find_access_token(%{ + connect_info: %{sec_websocket_protocol: [token]} + }), + do: token + + defp find_access_token(%{params: %{"access_token" => token}}), do: token + + defp find_access_token(_), do: nil end diff --git a/mix.exs b/mix.exs index 69e52e526..88b558a75 100644 --- a/mix.exs +++ b/mix.exs @@ -132,7 +132,8 @@ defmodule Pleroma.Mixfile do # Type `mix help deps` for examples and options. defp deps do [ - {:phoenix, "~> 1.7.3"}, + {:phoenix, + git: "https://github.com/feld/phoenix", branch: "v1.7.14-websocket-headers", override: true}, {:phoenix_ecto, "~> 4.4"}, {:ecto_sql, "~> 3.10"}, {:ecto_enum, "~> 1.4"}, diff --git a/mix.lock b/mix.lock index 61ede9e5e..a26ac0e84 100644 --- a/mix.lock +++ b/mix.lock @@ -65,7 +65,7 @@ "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "inet_cidr": {:hex, :inet_cidr, "1.0.8", "d26bb7bdbdf21ae401ead2092bf2bb4bf57fe44a62f5eaa5025280720ace8a40", [:mix], [], "hexpm", "d5b26da66603bb56c933c65214c72152f0de9a6ea53618b56d63302a68f6a90e"}, - "jason": {:hex, :jason, "1.4.3", "d3f984eeb96fe53b85d20e0b049f03e57d075b5acda3ac8d465c969a2536c17b", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9a90e868927f7c777689baa16d86f4d0e086d968db5c05d917ccff6d443e58a3"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "joken": {:hex, :joken, "2.6.0", "b9dd9b6d52e3e6fcb6c65e151ad38bf4bc286382b5b6f97079c47ade6b1bcc6a", [:mix], [{:jose, "~> 1.11.5", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5a95b05a71cd0b54abd35378aeb1d487a23a52c324fa7efdffc512b655b5aaa7"}, "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, @@ -94,7 +94,7 @@ "open_api_spex": {:hex, :open_api_spex, "3.18.2", "8c855e83bfe8bf81603d919d6e892541eafece3720f34d1700b58024dadde247", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "aa3e6dcfc0ad6a02596b2172662da21c9dd848dac145ea9e603f54e3d81b8d2b"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "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.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, + "phoenix": {:git, "https://github.com/feld/phoenix", "fb6dc76c657422e49600896c64aab4253fceaef6", [branch: "v1.7.14-websocket-headers"]}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"}, From 71ef9f9519e2617a0c05d7447bbc406ae4a8d849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 17 Aug 2024 16:36:27 +0200 Subject: [PATCH 003/212] Allow providing avatar/header descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- lib/pleroma/user.ex | 28 ++++++++++++++++--- .../api_spec/operations/account_operation.ex | 10 +++++++ .../controllers/account_controller.ex | 2 ++ .../web/mastodon_api/views/account_view.ex | 10 ++++++- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index c6c536943..f443b64ae 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -589,13 +589,21 @@ defmodule Pleroma.User do |> 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)) + |> put_change_if_present( + :avatar, + &put_upload(&1, :avatar, Map.get(params, :avatar_description, nil)) + ) + |> put_change_if_present( + :banner, + &put_upload(&1, :banner, Map.get(params, :header_description, nil)) + ) |> put_change_if_present(:background, &put_upload(&1, :background)) |> put_change_if_present( :pleroma_settings_store, &{:ok, Map.merge(struct.pleroma_settings_store, &1)} ) + |> maybe_update_image_description(:avatar, Map.get(params, :avatar_description)) + |> maybe_update_image_description(:banner, Map.get(params, :header_description)) |> validate_fields(false) end @@ -674,13 +682,25 @@ defmodule Pleroma.User do end end - defp put_upload(value, type) do + defp put_upload(value, type, description \\ nil) do with %Plug.Upload{} <- value, - {:ok, object} <- ActivityPub.upload(value, type: type) do + {:ok, object} <- ActivityPub.upload(value, type: type, description: description) do {:ok, object.data} end end + defp maybe_update_image_description(changeset, image_field, description) do + with {:image_missing, true} <- {:image_missing, not changed?(changeset, image_field)}, + {:existing_image, %{"id" => id}} <- + {:existing_image, Map.get(changeset.data, image_field)}, + {:object, %Object{} = object} <- {:object, Object.get_by_ap_id(id)}, + {:ok, object} <- Object.update_data(object, %{"name" => description}) do + put_change(changeset, image_field, object.data) + else + e -> changeset + end + end + def update_as_admin_changeset(struct, params) do struct |> update_changeset(params) diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index d9614bc48..21a779dcb 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -813,6 +813,16 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do allOf: [BooleanLike], nullable: true, description: "User's birthday will be visible" + }, + avatar_description: %Schema{ + type: :string, + nullable: true, + description: "Avatar image description." + }, + header_description: %Schema{ + type: :string, + nullable: true, + description: "Header image description." } }, example: %{ diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 54d46c86b..2302d6ed8 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -232,6 +232,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do |> Maps.put_if_present(:is_discoverable, params[:discoverable]) |> Maps.put_if_present(:birthday, params[:birthday]) |> Maps.put_if_present(:language, Pleroma.Web.Gettext.normalize_locale(params[:language])) + |> Maps.put_if_present(:avatar_description, params[:avatar_description]) + |> Maps.put_if_present(:header_description, params[:header_description]) # 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 6976ca6e5..bd8af265a 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -220,8 +220,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do avatar = User.avatar_url(user) |> MediaProxy.url() avatar_static = User.avatar_url(user) |> MediaProxy.preview_url(static: true) + avatar_description = image_description(user.avatar) header = User.banner_url(user) |> MediaProxy.url() header_static = User.banner_url(user) |> MediaProxy.preview_url(static: true) + header_description = image_description(user.banner) following_count = if !user.hide_follows_count or !user.hide_follows or self, @@ -323,7 +325,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do background_image: image_url(user.background) |> MediaProxy.url(), accepts_chat_messages: user.accepts_chat_messages, favicon: favicon - } + }, + avatar_description: avatar_description, + header_description: header_description } |> maybe_put_role(user, opts[:for]) |> maybe_put_settings(user, opts[:for], opts) @@ -346,6 +350,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do defp username_from_nickname(_), do: nil + defp image_description(%{"name" => name}), do: name + + defp image_description(_), do: "" + defp maybe_put_follow_requests_count( data, %User{id: user_id} = user, From 681765669c5b5bdc92079357d76c859e60bb49d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 17 Aug 2024 17:02:44 +0200 Subject: [PATCH 004/212] Add test for avatar description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- lib/pleroma/user.ex | 2 +- lib/pleroma/web/api_spec/schemas/account.ex | 4 ++ .../mastodon_api/update_credentials_test.exs | 42 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index f443b64ae..c3cb72fab 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -697,7 +697,7 @@ defmodule Pleroma.User do {:ok, object} <- Object.update_data(object, %{"name" => description}) do put_change(changeset, image_field, object.data) else - e -> changeset + _ -> changeset end end diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 8aeb821a8..32a0dd6cb 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -148,10 +148,13 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do } } } + avatar_description: %Schema{type: :string}, + header_description: %Schema{type: :string} }, example: %{ "acct" => "foobar", "avatar" => "https://mypleroma.com/images/avi.png", + "avatar_description" => "", "avatar_static" => "https://mypleroma.com/images/avi.png", "bot" => false, "created_at" => "2020-03-24T13:05:58.000Z", @@ -162,6 +165,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do "followers_count" => 0, "following_count" => 1, "header" => "https://mypleroma.com/images/banner.png", + "header_description" => "", "header_static" => "https://mypleroma.com/images/banner.png", "id" => "9tKi3esbG7OQgZ2920", "locked" => false, diff --git a/test/pleroma/web/mastodon_api/update_credentials_test.exs b/test/pleroma/web/mastodon_api/update_credentials_test.exs index bea0cae69..28d3b00db 100644 --- a/test/pleroma/web/mastodon_api/update_credentials_test.exs +++ b/test/pleroma/web/mastodon_api/update_credentials_test.exs @@ -430,6 +430,48 @@ defmodule Pleroma.Web.MastodonAPI.UpdateCredentialsTest do assert :ok == File.rm(Path.absname("test/tmp/large_binary.data")) end + test "adds avatar description with a new avatar", %{user: user, conn: conn} do + new_avatar = %Plug.Upload{ + content_type: "image/jpeg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + res = + patch(conn, "/api/v1/accounts/update_credentials", %{ + "avatar" => new_avatar, + "avatar_description" => "me and pleroma tan" + }) + + assert json_response_and_validate_schema(res, 200) + + user = User.get_by_id(user.id) + assert user.avatar["name"] == "me and pleroma tan" + end + + test "adds avatar description to existing avatar", %{user: user, conn: conn} do + new_avatar = %Plug.Upload{ + content_type: "image/jpeg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + assert user.avatar == %{} + + conn + |> patch("/api/v1/accounts/update_credentials", %{"avatar" => new_avatar}) + + assert conn + |> assign(:user, User.get_by_id(user.id)) + |> patch("/api/v1/accounts/update_credentials", %{ + "avatar_description" => "me and pleroma tan" + }) + |> json_response_and_validate_schema(200) + + user = User.get_by_id(user.id) + assert user.avatar["name"] == "me and pleroma tan" + end + test "Strip / from upload files", %{user: user, conn: conn} do new_image = %Plug.Upload{ content_type: "image/jpeg", From 071452a5d5e4e8d38d9c31bad171085574327fee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 17 Aug 2024 17:03:12 +0200 Subject: [PATCH 005/212] Update changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- changelog.d/profile-image-descriptions.add | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/profile-image-descriptions.add diff --git a/changelog.d/profile-image-descriptions.add b/changelog.d/profile-image-descriptions.add new file mode 100644 index 000000000..85cc48083 --- /dev/null +++ b/changelog.d/profile-image-descriptions.add @@ -0,0 +1 @@ +Allow providing avatar/header descriptions \ No newline at end of file From 855c5a234f4ca743303f1b88974665d7b9f58684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 17 Aug 2024 17:05:47 +0200 Subject: [PATCH 006/212] Update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../development/API/differences_in_mastoapi_responses.md | 9 ++++++++- lib/pleroma/web/api_spec/schemas/account.ex | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index 41464e802..114d6e32d 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -97,13 +97,18 @@ Endpoints which accept `with_relationships` parameter: - `/api/v1/accounts/:id/following` - `/api/v1/mutes` +Has these additional fields: + +- `avatar_description`: string, image description for user avatar, defaults to empty string +- `header_description`: string, image description for user banner, defaults to empty string + 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` (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_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 @@ -255,6 +260,8 @@ Additional parameters can be added to the JSON body/Form data: - `actor_type` - the type of this account. - `accepts_chat_messages` - if false, this account will reject all chat messages. - `language` - user's preferred language for receiving emails (digest, confirmation, etc.) +- `avatar_description` - image description for user avatar +- `header_description` - image description for user banner All images (avatar, banner and background) can be reset to the default by sending an empty string ("") instead of a file. diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 32a0dd6cb..3f2310df9 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -147,7 +147,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do } } } - } + }, avatar_description: %Schema{type: :string}, header_description: %Schema{type: :string} }, From c802f3b7f61e1c4bbe2f4eec757802e30f88b6a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 17 Aug 2024 19:58:32 +0200 Subject: [PATCH 007/212] Validate media description length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- lib/pleroma/user.ex | 24 ++++++++++++++--- .../controllers/account_controller.ex | 6 +++++ .../mastodon_api/update_credentials_test.exs | 27 +++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index c3cb72fab..517009253 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -586,16 +586,18 @@ defmodule Pleroma.User do |> validate_length(:bio, max: bio_limit) |> validate_length(:name, min: 1, max: name_limit) |> validate_inclusion(:actor_type, Pleroma.Constants.allowed_user_actor_types()) + |> validate_image_description(:avatar_description, params) + |> validate_image_description(:header_description, params) |> put_fields() |> put_emoji() |> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)}) |> put_change_if_present( :avatar, - &put_upload(&1, :avatar, Map.get(params, :avatar_description, nil)) + &put_upload(&1, :avatar, Map.get(params, :avatar_description)) ) |> put_change_if_present( :banner, - &put_upload(&1, :banner, Map.get(params, :header_description, nil)) + &put_upload(&1, :banner, Map.get(params, :header_description)) ) |> put_change_if_present(:background, &put_upload(&1, :background)) |> put_change_if_present( @@ -689,7 +691,20 @@ defmodule Pleroma.User do end end - defp maybe_update_image_description(changeset, image_field, description) do + defp validate_image_description(changeset, key, params) do + description_limit = Config.get([:instance, :description_limit], 5_000) + description = Map.get(params, key) + + if is_binary(description) and String.length(description) > description_limit do + changeset + |> add_error(key, "#{key} is too long") + else + changeset + end + end + + defp maybe_update_image_description(changeset, image_field, description) + when is_binary(description) do with {:image_missing, true} <- {:image_missing, not changed?(changeset, image_field)}, {:existing_image, %{"id" => id}} <- {:existing_image, Map.get(changeset.data, image_field)}, @@ -697,10 +712,13 @@ defmodule Pleroma.User do {:ok, object} <- Object.update_data(object, %{"name" => description}) do put_change(changeset, image_field, object.data) else + {:description_too_long, true} -> {:error} _ -> changeset end end + defp maybe_update_image_description(changeset, _, _), do: changeset + def update_as_admin_changeset(struct, params) do struct |> update_changeset(params) diff --git a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex index 2302d6ed8..68157b0c4 100644 --- a/lib/pleroma/web/mastodon_api/controllers/account_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/account_controller.ex @@ -279,6 +279,12 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do {:error, %Ecto.Changeset{errors: [{:name, {_, _}} | _]}} -> render_error(conn, :request_entity_too_large, "Name is too long") + {:error, %Ecto.Changeset{errors: [{:avatar_description, {_, _}} | _]}} -> + render_error(conn, :request_entity_too_large, "Avatar description is too long") + + {:error, %Ecto.Changeset{errors: [{:header_description, {_, _}} | _]}} -> + render_error(conn, :request_entity_too_large, "Banner description is too long") + {:error, %Ecto.Changeset{errors: [{:fields, {"invalid", _}} | _]}} -> render_error(conn, :request_entity_too_large, "One or more field entries are too long") diff --git a/test/pleroma/web/mastodon_api/update_credentials_test.exs b/test/pleroma/web/mastodon_api/update_credentials_test.exs index 28d3b00db..97ad2e849 100644 --- a/test/pleroma/web/mastodon_api/update_credentials_test.exs +++ b/test/pleroma/web/mastodon_api/update_credentials_test.exs @@ -472,6 +472,33 @@ defmodule Pleroma.Web.MastodonAPI.UpdateCredentialsTest do assert user.avatar["name"] == "me and pleroma tan" end + test "limit", %{user: user, conn: conn} do + new_header = %Plug.Upload{ + content_type: "image/jpeg", + path: Path.absname("test/fixtures/image.jpg"), + filename: "an_image.jpg" + } + + assert user.banner == %{} + + conn + |> patch("/api/v1/accounts/update_credentials", %{"header" => new_header}) + + description_limit = Config.get([:instance, :description_limit], 100) + + description = String.duplicate(".", description_limit + 1) + + conn = + conn + |> assign(:user, User.get_by_id(user.id)) + |> patch("/api/v1/accounts/update_credentials", %{ + "header_description" => description + }) + + assert %{"error" => "Banner description is too long"} = + json_response_and_validate_schema(conn, 413) + end + test "Strip / from upload files", %{user: user, conn: conn} do new_image = %Plug.Upload{ content_type: "image/jpeg", From 3498662712c088cf578e7241c12aa1e5da42745a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 17 Aug 2024 19:59:39 +0200 Subject: [PATCH 008/212] Move new fields to pleroma object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- docs/development/API/differences_in_mastoapi_responses.md | 7 ++----- lib/pleroma/web/api_spec/schemas/account.ex | 8 ++++---- lib/pleroma/web/mastodon_api/views/account_view.ex | 8 ++++---- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index 114d6e32d..22a26b77b 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -97,11 +97,6 @@ Endpoints which accept `with_relationships` parameter: - `/api/v1/accounts/:id/following` - `/api/v1/mutes` -Has these additional fields: - -- `avatar_description`: string, image description for user avatar, defaults to empty string -- `header_description`: string, image description for user banner, defaults to empty string - Has these additional fields under the `pleroma` object: - `ap_id`: nullable URL string, ActivityPub id of the user @@ -125,6 +120,8 @@ 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. - `accepts_chat_messages`: boolean, but can be null if we don't have that information about a user - `favicon`: nullable URL string, Favicon image of the user's instance +- `avatar_description`: string, image description for user avatar, defaults to empty string +- `header_description`: string, image description for user banner, defaults to empty string ### Source diff --git a/lib/pleroma/web/api_spec/schemas/account.ex b/lib/pleroma/web/api_spec/schemas/account.ex index 3f2310df9..1f73ef60c 100644 --- a/lib/pleroma/web/api_spec/schemas/account.ex +++ b/lib/pleroma/web/api_spec/schemas/account.ex @@ -111,7 +111,9 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do format: :uri, nullable: true, description: "Favicon image of the user's instance" - } + }, + avatar_description: %Schema{type: :string}, + header_description: %Schema{type: :string} } }, source: %Schema{ @@ -147,9 +149,7 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Account do } } } - }, - avatar_description: %Schema{type: :string}, - header_description: %Schema{type: :string} + } }, example: %{ "acct" => "foobar", diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index bd8af265a..0643b8f14 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -324,10 +324,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do skip_thread_containment: user.skip_thread_containment, background_image: image_url(user.background) |> MediaProxy.url(), accepts_chat_messages: user.accepts_chat_messages, - favicon: favicon - }, - avatar_description: avatar_description, - header_description: header_description + favicon: favicon, + avatar_description: avatar_description, + header_description: header_description + } } |> maybe_put_role(user, opts[:for]) |> maybe_put_settings(user, opts[:for], opts) From 917ac89b4f944a80b1d168fd07d94c762ee04ed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 17 Aug 2024 20:01:25 +0200 Subject: [PATCH 009/212] Update tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- test/pleroma/web/mastodon_api/views/account_view_test.exs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 dca64853d..0301a4cca 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -96,7 +96,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do hide_follows_count: false, relationship: %{}, skip_thread_containment: false, - accepts_chat_messages: nil + accepts_chat_messages: nil, + avatar_description: "", + header_description: "" } } @@ -340,7 +342,9 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do hide_follows_count: false, relationship: %{}, skip_thread_containment: false, - accepts_chat_messages: nil + accepts_chat_messages: nil, + avatar_description: "", + header_description: "" } } From 010edcbcb51dfddc83d5a3810c257c1678429c2d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 21 Aug 2024 14:50:19 -0400 Subject: [PATCH 010/212] Use Map.filter now that minimum Elixir version is 1.13 --- lib/pleroma/maps.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/pleroma/maps.ex b/lib/pleroma/maps.ex index 5020a8ff8..1afbde484 100644 --- a/lib/pleroma/maps.ex +++ b/lib/pleroma/maps.ex @@ -20,15 +20,13 @@ defmodule Pleroma.Maps do end def filter_empty_values(data) do - # TODO: Change to Map.filter in Elixir 1.13+ data - |> Enum.filter(fn + |> Map.filter(fn {_k, nil} -> false {_k, ""} -> false {_k, []} -> false {_k, %{} = v} -> Map.keys(v) != [] {_k, _v} -> true end) - |> Map.new() end end From e65555e8c5cacb36a404579f56fb501a7fba0781 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 21 Aug 2024 15:11:41 -0400 Subject: [PATCH 011/212] Remove workaround for URI.merge bug on nil fields before Elixir 1.13 https://github.com/elixir-lang/elixir/issues/10771 --- lib/pleroma/web/mastodon_api/views/status_view.ex | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 747638c53..1b78477d0 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -803,19 +803,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do defp build_application(_), do: nil - # Workaround for Elixir issue #10771 - # Avoid applying URI.merge unless necessary - # TODO: revert to always attempting URI.merge(image_url_data, page_url_data) - # when Elixir 1.12 is the minimum supported version - @spec build_image_url(struct() | nil, struct()) :: String.t() | nil - defp build_image_url( - %URI{scheme: image_scheme, host: image_host} = image_url_data, - %URI{} = _page_url_data - ) - when not is_nil(image_scheme) and not is_nil(image_host) do - image_url_data |> to_string - end - + @spec build_image_url(URI.t(), URI.t()) :: String.t() defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do URI.merge(page_url_data, image_url_data) |> to_string end From 5138a4984ba8cf04e0b6015a7a9253a9013e013e Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 21 Aug 2024 15:24:33 -0400 Subject: [PATCH 012/212] Skip changelog --- changelog.d/todo-cleanup.skip | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 changelog.d/todo-cleanup.skip diff --git a/changelog.d/todo-cleanup.skip b/changelog.d/todo-cleanup.skip new file mode 100644 index 000000000..e69de29bb From 649e51b581327eb34d31e0160ea70d1cba281f9a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 22 Aug 2024 11:29:44 -0400 Subject: [PATCH 013/212] Fix Oban jobs for imports --- changelog.d/user-imports.fix | 1 + lib/pleroma/user/import.ex | 6 +++--- test/pleroma/user/import_test.exs | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 changelog.d/user-imports.fix diff --git a/changelog.d/user-imports.fix b/changelog.d/user-imports.fix new file mode 100644 index 000000000..9f39dfeda --- /dev/null +++ b/changelog.d/user-imports.fix @@ -0,0 +1 @@ +Imports of blocks, mutes, and following would retry until Oban runs out of attempts due to incorrect return value being considered an error. diff --git a/lib/pleroma/user/import.ex b/lib/pleroma/user/import.ex index 11905237c..bee586234 100644 --- a/lib/pleroma/user/import.ex +++ b/lib/pleroma/user/import.ex @@ -18,7 +18,7 @@ defmodule Pleroma.User.Import do fn identifier -> with {:ok, %User{} = muted_user} <- User.get_or_fetch(identifier), {:ok, _} <- User.mute(user, muted_user) do - muted_user + {:ok, muted_user} else error -> handle_error(:mutes_import, identifier, error) end @@ -32,7 +32,7 @@ defmodule Pleroma.User.Import do fn identifier -> with {:ok, %User{} = blocked} <- User.get_or_fetch(identifier), {:ok, _block} <- CommonAPI.block(blocked, blocker) do - blocked + {:ok, blocked} else error -> handle_error(:blocks_import, identifier, error) end @@ -47,7 +47,7 @@ defmodule Pleroma.User.Import do with {:ok, %User{} = followed} <- User.get_or_fetch(identifier), {:ok, follower, followed} <- User.maybe_direct_follow(follower, followed), {:ok, _, _, _} <- CommonAPI.follow(followed, follower) do - followed + {:ok, followed} else error -> handle_error(:follow_import, identifier, error) end diff --git a/test/pleroma/user/import_test.exs b/test/pleroma/user/import_test.exs index f75305e0e..54c521698 100644 --- a/test/pleroma/user/import_test.exs +++ b/test/pleroma/user/import_test.exs @@ -29,7 +29,7 @@ defmodule Pleroma.User.ImportTest do assert {:ok, result} = ObanHelpers.perform(job) assert is_list(result) - assert result == [refresh_record(user2), refresh_record(user3)] + assert result == [{:ok, refresh_record(user2)}, {:ok, refresh_record(user3)}] assert User.following?(user1, user2) assert User.following?(user1, user3) end @@ -48,7 +48,7 @@ defmodule Pleroma.User.ImportTest do assert {:ok, result} = ObanHelpers.perform(job) assert is_list(result) - assert result == [user2, user3] + assert result == [{:ok, user2}, {:ok, user3}] assert User.blocks?(user1, user2) assert User.blocks?(user1, user3) end @@ -67,7 +67,7 @@ defmodule Pleroma.User.ImportTest do assert {:ok, result} = ObanHelpers.perform(job) assert is_list(result) - assert result == [user2, user3] + assert result == [{:ok, user2}, {:ok, user3}] assert User.mutes?(user1, user2) assert User.mutes?(user1, user3) end From a9aa810d3dadaac5a40d18f56ab41b6276206db1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 22 Aug 2024 12:49:32 -0400 Subject: [PATCH 014/212] Change imports to generate an Oban job per each task --- changelog.d/user-imports.fix | 2 +- lib/pleroma/user/import.ex | 144 ++++++++++-------- .../controllers/user_import_controller.ex | 8 +- lib/pleroma/workers/background_worker.ex | 6 +- test/pleroma/user/import_test.exs | 27 ++-- .../user_import_controller_test.exs | 92 +++++++---- 6 files changed, 170 insertions(+), 109 deletions(-) diff --git a/changelog.d/user-imports.fix b/changelog.d/user-imports.fix index 9f39dfeda..0076c73d7 100644 --- a/changelog.d/user-imports.fix +++ b/changelog.d/user-imports.fix @@ -1 +1 @@ -Imports of blocks, mutes, and following would retry until Oban runs out of attempts due to incorrect return value being considered an error. +Imports of blocks, mutes, and follows would retry repeatedly due to incorrect error handling and all work executed in a single job diff --git a/lib/pleroma/user/import.ex b/lib/pleroma/user/import.ex index bee586234..400e62153 100644 --- a/lib/pleroma/user/import.ex +++ b/lib/pleroma/user/import.ex @@ -5,6 +5,7 @@ defmodule Pleroma.User.Import do use Ecto.Schema + alias Pleroma.Repo alias Pleroma.User alias Pleroma.Web.CommonAPI alias Pleroma.Workers.BackgroundWorker @@ -12,80 +13,103 @@ defmodule Pleroma.User.Import do require Logger @spec perform(atom(), User.t(), list()) :: :ok | list() | {:error, any()} - def perform(:mutes_import, %User{} = user, [_ | _] = identifiers) do - Enum.map( - identifiers, - fn identifier -> - with {:ok, %User{} = muted_user} <- User.get_or_fetch(identifier), - {:ok, _} <- User.mute(user, muted_user) do - {:ok, muted_user} - else - error -> handle_error(:mutes_import, identifier, error) - end - end - ) + def perform(:mute_import, %User{} = user, actor) do + with {:ok, %User{} = muted_user} <- User.get_or_fetch(actor), + {_, false} <- {:existing_mute, User.mutes_user?(user, muted_user)}, + {:ok, _} <- User.mute(user, muted_user), + # User.mute/2 returns a FollowingRelationship not a %User{} like we get + # from CommonAPI.block/2 or CommonAPI.follow/2, so we fetch again to + # return the target actor for consistency + {:ok, muted_user} <- User.get_or_fetch(actor) do + {:ok, muted_user} + else + {:existing_mute, true} -> :ok + error -> handle_error(:mutes_import, actor, error) + end end - def perform(:blocks_import, %User{} = blocker, [_ | _] = identifiers) do - Enum.map( - identifiers, - fn identifier -> - with {:ok, %User{} = blocked} <- User.get_or_fetch(identifier), - {:ok, _block} <- CommonAPI.block(blocked, blocker) do - {:ok, blocked} - else - error -> handle_error(:blocks_import, identifier, error) - end - end - ) + def perform(:block_import, %User{} = user, actor) do + with {:ok, %User{} = blocked} <- User.get_or_fetch(actor), + {_, false} <- {:existing_block, User.blocks_user?(user, blocked)}, + {:ok, _block} <- CommonAPI.block(blocked, user) do + {:ok, blocked} + else + {:existing_block, true} -> :ok + error -> handle_error(:blocks_import, actor, error) + end end - def perform(:follow_import, %User{} = follower, [_ | _] = identifiers) do - Enum.map( - identifiers, - fn identifier -> - with {:ok, %User{} = followed} <- User.get_or_fetch(identifier), - {:ok, follower, followed} <- User.maybe_direct_follow(follower, followed), - {:ok, _, _, _} <- CommonAPI.follow(followed, follower) do - {:ok, followed} - else - error -> handle_error(:follow_import, identifier, error) - end - end - ) + def perform(:follow_import, %User{} = user, actor) do + with {:ok, %User{} = followed} <- User.get_or_fetch(actor), + {_, false} <- {:existing_follow, User.following?(user, followed)}, + {:ok, user, followed} <- User.maybe_direct_follow(user, followed), + {:ok, _, _, _} <- CommonAPI.follow(followed, user) do + {:ok, followed} + else + {:existing_follow, true} -> :ok + error -> handle_error(:follow_import, actor, error) + end end - def perform(_, _, _), do: :ok - defp handle_error(op, user_id, error) do Logger.debug("#{op} failed for #{user_id} with: #{inspect(error)}") error end - def blocks_import(%User{} = blocker, [_ | _] = identifiers) do - BackgroundWorker.new(%{ - "op" => "blocks_import", - "user_id" => blocker.id, - "identifiers" => identifiers - }) - |> Oban.insert() + def blocks_import(%User{} = user, [_ | _] = actors) do + jobs = + Repo.checkout(fn -> + Enum.reduce(actors, [], fn actor, acc -> + {:ok, job} = + BackgroundWorker.new(%{ + "op" => "block_import", + "user_id" => user.id, + "actor" => actor + }) + |> Oban.insert() + + acc ++ [job] + end) + end) + + {:ok, jobs} end - def follow_import(%User{} = follower, [_ | _] = identifiers) do - BackgroundWorker.new(%{ - "op" => "follow_import", - "user_id" => follower.id, - "identifiers" => identifiers - }) - |> Oban.insert() + def follows_import(%User{} = user, [_ | _] = actors) do + jobs = + Repo.checkout(fn -> + Enum.reduce(actors, [], fn actor, acc -> + {:ok, job} = + BackgroundWorker.new(%{ + "op" => "follow_import", + "user_id" => user.id, + "actor" => actor + }) + |> Oban.insert() + + acc ++ [job] + end) + end) + + {:ok, jobs} end - def mutes_import(%User{} = user, [_ | _] = identifiers) do - BackgroundWorker.new(%{ - "op" => "mutes_import", - "user_id" => user.id, - "identifiers" => identifiers - }) - |> Oban.insert() + def mutes_import(%User{} = user, [_ | _] = actors) do + jobs = + Repo.checkout(fn -> + Enum.reduce(actors, [], fn actor, acc -> + {:ok, job} = + BackgroundWorker.new(%{ + "op" => "mute_import", + "user_id" => user.id, + "actor" => actor + }) + |> Oban.insert() + + acc ++ [job] + end) + end) + + {:ok, jobs} end end diff --git a/lib/pleroma/web/pleroma_api/controllers/user_import_controller.ex b/lib/pleroma/web/pleroma_api/controllers/user_import_controller.ex index 96466f192..d65c30dab 100644 --- a/lib/pleroma/web/pleroma_api/controllers/user_import_controller.ex +++ b/lib/pleroma/web/pleroma_api/controllers/user_import_controller.ex @@ -38,8 +38,8 @@ defmodule Pleroma.Web.PleromaAPI.UserImportController do |> Enum.map(&(&1 |> String.trim() |> String.trim_leading("@"))) |> Enum.reject(&(&1 == "")) - User.Import.follow_import(follower, identifiers) - json(conn, "job started") + User.Import.follows_import(follower, identifiers) + json(conn, "jobs started") end def blocks( @@ -55,7 +55,7 @@ defmodule Pleroma.Web.PleromaAPI.UserImportController do defp do_block(%{assigns: %{user: blocker}} = conn, list) do User.Import.blocks_import(blocker, prepare_user_identifiers(list)) - json(conn, "job started") + json(conn, "jobs started") end def mutes( @@ -71,7 +71,7 @@ defmodule Pleroma.Web.PleromaAPI.UserImportController do defp do_mute(%{assigns: %{user: user}} = conn, list) do User.Import.mutes_import(user, prepare_user_identifiers(list)) - json(conn, "job started") + json(conn, "jobs started") end defp prepare_user_identifiers(list) do diff --git a/lib/pleroma/workers/background_worker.ex b/lib/pleroma/workers/background_worker.ex index 60da2d5ca..4737c6ea2 100644 --- a/lib/pleroma/workers/background_worker.ex +++ b/lib/pleroma/workers/background_worker.ex @@ -19,10 +19,10 @@ defmodule Pleroma.Workers.BackgroundWorker do User.perform(:force_password_reset, user) end - def perform(%Job{args: %{"op" => op, "user_id" => user_id, "identifiers" => identifiers}}) - when op in ["blocks_import", "follow_import", "mutes_import"] do + def perform(%Job{args: %{"op" => op, "user_id" => user_id, "actor" => actor}}) + when op in ["block_import", "follow_import", "mute_import"] do user = User.get_cached_by_id(user_id) - {:ok, User.Import.perform(String.to_existing_atom(op), user, identifiers)} + User.Import.perform(String.to_existing_atom(op), user, actor) end def perform(%Job{ diff --git a/test/pleroma/user/import_test.exs b/test/pleroma/user/import_test.exs index 54c521698..1d6469a4f 100644 --- a/test/pleroma/user/import_test.exs +++ b/test/pleroma/user/import_test.exs @@ -25,11 +25,12 @@ defmodule Pleroma.User.ImportTest do user3.nickname ] - {:ok, job} = User.Import.follow_import(user1, identifiers) + {:ok, jobs} = User.Import.follows_import(user1, identifiers) + + for job <- jobs do + assert {:ok, %User{}} = ObanHelpers.perform(job) + end - assert {:ok, result} = ObanHelpers.perform(job) - assert is_list(result) - assert result == [{:ok, refresh_record(user2)}, {:ok, refresh_record(user3)}] assert User.following?(user1, user2) assert User.following?(user1, user3) end @@ -44,11 +45,12 @@ defmodule Pleroma.User.ImportTest do user3.nickname ] - {:ok, job} = User.Import.blocks_import(user1, identifiers) + {:ok, jobs} = User.Import.blocks_import(user1, identifiers) + + for job <- jobs do + assert {:ok, %User{}} = ObanHelpers.perform(job) + end - assert {:ok, result} = ObanHelpers.perform(job) - assert is_list(result) - assert result == [{:ok, user2}, {:ok, user3}] assert User.blocks?(user1, user2) assert User.blocks?(user1, user3) end @@ -63,11 +65,12 @@ defmodule Pleroma.User.ImportTest do user3.nickname ] - {:ok, job} = User.Import.mutes_import(user1, identifiers) + {:ok, jobs} = User.Import.mutes_import(user1, identifiers) + + for job <- jobs do + assert {:ok, %User{}} = ObanHelpers.perform(job) + end - assert {:ok, result} = ObanHelpers.perform(job) - assert is_list(result) - assert result == [{:ok, user2}, {:ok, user3}] assert User.mutes?(user1, user2) assert User.mutes?(user1, user3) end diff --git a/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs b/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs index 52a62e416..efdc743e3 100644 --- a/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs +++ b/test/pleroma/web/pleroma_api/controllers/user_import_controller_test.exs @@ -22,7 +22,7 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do test "it returns HTTP 200", %{conn: conn} do user2 = insert(:user) - assert "job started" == + assert "jobs started" == conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/follow_import", %{"list" => "#{user2.ap_id}"}) @@ -38,7 +38,7 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do "Account address,Show boosts\n#{user2.ap_id},true" end} ]) do - assert "job started" == + assert "jobs started" == conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/follow_import", %{ @@ -46,9 +46,9 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do }) |> json_response_and_validate_schema(200) - assert [{:ok, job_result}] = ObanHelpers.perform_all() - assert job_result == [refresh_record(user2)] - assert [%Pleroma.User{follower_count: 1}] = job_result + assert [{:ok, updated_user}] = ObanHelpers.perform_all() + assert updated_user.id == user2.id + assert updated_user.follower_count == 1 end end @@ -63,7 +63,7 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do }) |> json_response_and_validate_schema(200) - assert response == "job started" + assert response == "jobs started" end test "requires 'follow' or 'write:follows' permissions" do @@ -102,14 +102,20 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do ] |> Enum.join("\n") - assert "job started" == + assert "jobs started" == conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/follow_import", %{"list" => identifiers}) |> json_response_and_validate_schema(200) - assert [{:ok, job_result}] = ObanHelpers.perform_all() - assert job_result == Enum.map(users, &refresh_record/1) + results = ObanHelpers.perform_all() + + returned_users = + for {_, returned_user} <- results do + returned_user + end + + assert returned_users == Enum.map(users, &refresh_record/1) end end @@ -120,7 +126,7 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do test "it returns HTTP 200", %{conn: conn} do user2 = insert(:user) - assert "job started" == + assert "jobs started" == conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/blocks_import", %{"list" => "#{user2.ap_id}"}) @@ -133,7 +139,7 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do with_mocks([ {File, [], read!: fn "blocks_list.txt" -> "#{user2.ap_id} #{user3.ap_id}" end} ]) do - assert "job started" == + assert "jobs started" == conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/blocks_import", %{ @@ -141,8 +147,14 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do }) |> json_response_and_validate_schema(200) - assert [{:ok, job_result}] = ObanHelpers.perform_all() - assert job_result == users + results = ObanHelpers.perform_all() + + returned_users = + for {_, returned_user} <- results do + returned_user + end + + assert returned_users == users end end @@ -159,14 +171,25 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do ] |> Enum.join(" ") - assert "job started" == + assert "jobs started" == conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/blocks_import", %{"list" => identifiers}) |> json_response_and_validate_schema(200) - assert [{:ok, job_result}] = ObanHelpers.perform_all() - assert job_result == users + results = ObanHelpers.perform_all() + + returned_user_ids = + for {_, user} <- results do + user.id + end + + original_user_ids = + for user <- users do + user.id + end + + assert match?(^original_user_ids, returned_user_ids) end end @@ -177,24 +200,25 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do test "it returns HTTP 200", %{user: user, conn: conn} do user2 = insert(:user) - assert "job started" == + assert "jobs started" == conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/mutes_import", %{"list" => "#{user2.ap_id}"}) |> json_response_and_validate_schema(200) - assert [{:ok, job_result}] = ObanHelpers.perform_all() - assert job_result == [user2] + [{:ok, result_user}] = ObanHelpers.perform_all() + + assert result_user == refresh_record(user2) assert Pleroma.User.mutes?(user, user2) end test "it imports mutes users from file", %{user: user, conn: conn} do - users = [user2, user3] = insert_list(2, :user) + [user2, user3] = insert_list(2, :user) with_mocks([ {File, [], read!: fn "mutes_list.txt" -> "#{user2.ap_id} #{user3.ap_id}" end} ]) do - assert "job started" == + assert "jobs started" == conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/mutes_import", %{ @@ -202,14 +226,19 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do }) |> json_response_and_validate_schema(200) - assert [{:ok, job_result}] = ObanHelpers.perform_all() - assert job_result == users - assert Enum.all?(users, &Pleroma.User.mutes?(user, &1)) + results = ObanHelpers.perform_all() + + returned_users = + for {_, returned_user} <- results do + returned_user + end + + assert Enum.all?(returned_users, &Pleroma.User.mutes?(user, &1)) end end test "it imports mutes with different nickname variations", %{user: user, conn: conn} do - users = [user2, user3, user4, user5, user6] = insert_list(5, :user) + [user2, user3, user4, user5, user6] = insert_list(5, :user) identifiers = [ @@ -221,15 +250,20 @@ defmodule Pleroma.Web.PleromaAPI.UserImportControllerTest do ] |> Enum.join(" ") - assert "job started" == + assert "jobs started" == conn |> put_req_header("content-type", "application/json") |> post("/api/pleroma/mutes_import", %{"list" => identifiers}) |> json_response_and_validate_schema(200) - assert [{:ok, job_result}] = ObanHelpers.perform_all() - assert job_result == users - assert Enum.all?(users, &Pleroma.User.mutes?(user, &1)) + results = ObanHelpers.perform_all() + + returned_users = + for {_, returned_user} <- results do + returned_user + end + + assert Enum.all?(returned_users, &Pleroma.User.mutes?(user, &1)) end end end From 39108c5f128d9d5933f038773dc72d2e25a49564 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 22 Aug 2024 13:43:01 -0400 Subject: [PATCH 015/212] Remove unnecessary re-fetch of the actor --- lib/pleroma/user/import.ex | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/pleroma/user/import.ex b/lib/pleroma/user/import.ex index 400e62153..b79fa88eb 100644 --- a/lib/pleroma/user/import.ex +++ b/lib/pleroma/user/import.ex @@ -16,11 +16,7 @@ defmodule Pleroma.User.Import do def perform(:mute_import, %User{} = user, actor) do with {:ok, %User{} = muted_user} <- User.get_or_fetch(actor), {_, false} <- {:existing_mute, User.mutes_user?(user, muted_user)}, - {:ok, _} <- User.mute(user, muted_user), - # User.mute/2 returns a FollowingRelationship not a %User{} like we get - # from CommonAPI.block/2 or CommonAPI.follow/2, so we fetch again to - # return the target actor for consistency - {:ok, muted_user} <- User.get_or_fetch(actor) do + {:ok, _} <- User.mute(user, muted_user) do {:ok, muted_user} else {:existing_mute, true} -> :ok From 99ace19ca96e64edd25ad0ceb024be6375dc5e01 Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Thu, 1 Aug 2024 07:12:38 +0000 Subject: [PATCH 016/212] Added translation using Weblate (Chinese (Simplified)) --- .../zh_Hans/LC_MESSAGES/oauth_scopes.po | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 priv/gettext/zh_Hans/LC_MESSAGES/oauth_scopes.po diff --git a/priv/gettext/zh_Hans/LC_MESSAGES/oauth_scopes.po b/priv/gettext/zh_Hans/LC_MESSAGES/oauth_scopes.po new file mode 100644 index 000000000..f2acac90a --- /dev/null +++ b/priv/gettext/zh_Hans/LC_MESSAGES/oauth_scopes.po @@ -0,0 +1,273 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-08-01 10:12+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Automatically generated\n" +"Language-Team: none\n" +"Language: zh_Hans\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Translate Toolkit 3.7.2\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 has no +## effect: edit them in PO (.po) files instead. + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:read" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:write" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "follow" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:accounts" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:blocks" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:bookmarks" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:favourites" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:filters" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:follows" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:lists" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:notifications" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:search" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:statuses" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:accounts" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:blocks" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:bookmarks" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:conversations" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:favourites" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:filters" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:follows" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:lists" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:media" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:mutes" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:notifications" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:statuses" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:read:accounts" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:read:chats" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:read:invites" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:read:media_proxy_caches" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:read:reports" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:read:statuses" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:write:accounts" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:write:chats" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:write:follows" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:write:invites" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:write:media_proxy_caches" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:write:reports" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "admin:write:statuses" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:mutes" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "push" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:backups" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:chats" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:media" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "read:reports" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:chats" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:follow" +msgstr "" + +#: lib/pleroma/web/api_spec/scopes/translator.ex:5 +#, elixir-autogen, elixir-format +msgid "write:reports" +msgstr "" From 743c4f2f5fefc496f695bc5183ffdae996e4f308 Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Thu, 1 Aug 2024 06:49:21 +0000 Subject: [PATCH 017/212] Translated using Weblate (Chinese (Simplified)) Currently translated at 19.8% (193 of 974 strings) Translation: Pleroma/Pleroma Backend (domain config_descriptions) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-config_descriptions/zh_Hans/ --- .../LC_MESSAGES/config_descriptions.po | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po b/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po index ff9ad5245..281fad932 100644 --- a/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po +++ b/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po @@ -3,9 +3,9 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-07-21 04:21+0300\n" -"PO-Revision-Date: 2022-07-24 10:04+0000\n" -"Last-Translator: Yating Zhan \n" -"Language-Team: Chinese (Simplified) \n" +"Language-Team: Chinese (Simplified) \n" "Language: zh_Hans\n" "MIME-Version: 1.0\n" @@ -49,6 +49,8 @@ msgstr "Mime 类型设置" msgctxt "config description at :pleroma" msgid "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)" msgstr "" +"允许设置令牌以不使用普通用户令牌来授权管理员权限。在参数后加上 `admin_token` " +"来启用该功能。(可用时可以考虑使用 HTTP Basic Auth 或 基于 OAuth 的鉴定方式)" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -126,7 +128,7 @@ msgstr "ActivityPub 相关设置" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:assets" msgid "This section configures assets to be used with various frontends. Currently the only option relates to mascots on the mastodon frontend" -msgstr "" +msgstr "该部分配置不同前端使用的资源。目前该选项只对 Mastodon 前端的吉祥物有效" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -226,7 +228,7 @@ msgid "Majic/libmagic configuration" msgstr "Majic/libmagic 配置" #: lib/pleroma/docs/translator.ex:5 -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:manifest" msgid "This section describe PWA manifest instance-specific values. Currently this option relate only for MastoFE." msgstr "此处提供针对特定实例的 PWA manifest 数值。目前相关设定尚只支持 MastoFE。" @@ -244,10 +246,10 @@ msgid "Media proxy" msgstr "媒体代理" #: lib/pleroma/docs/translator.ex:5 -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:modules" msgid "Custom Runtime Modules" -msgstr "自定义 Runtime 模块" +msgstr "自定义运行库模块" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -299,7 +301,7 @@ msgstr "拒绝提及特定用户的讯息" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_normalize_markup" msgid "MRF NormalizeMarkup settings. Scrub configured hypertext markup." -msgstr "" +msgstr "MRF NomalizeMarkup 设置。清楚超文本标记。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -317,7 +319,7 @@ msgstr "RejectNonPublic 丢弃有非公开的可见性设置的文章。" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_simple" msgid "Simple ingress policies" -msgstr "" +msgstr "简单入口流量控制" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -608,13 +610,13 @@ msgstr "邮件通知" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:emoji" msgid "Emoji" -msgstr "Emoji" +msgstr "表情符号" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:features" msgid "Features" -msgstr "特性" +msgstr "功能" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -3607,7 +3609,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:assets > :mascots" msgid "Mascots" -msgstr "" +msgstr "吉祥物" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format From 1902323e97f36c71414304113473c4daddd166b1 Mon Sep 17 00:00:00 2001 From: Yating Zhan Date: Thu, 1 Aug 2024 08:13:38 +0000 Subject: [PATCH 018/212] Translated using Weblate (Chinese (Simplified)) Currently translated at 19.8% (193 of 974 strings) Translation: Pleroma/Pleroma Backend (domain config_descriptions) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-config_descriptions/zh_Hans/ --- priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po b/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po index 281fad932..808dd8bdc 100644 --- a/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po +++ b/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po @@ -4,7 +4,7 @@ msgstr "" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-07-21 04:21+0300\n" "PO-Revision-Date: 2024-08-01 08:17+0000\n" -"Last-Translator: Eric Zhang \n" +"Last-Translator: Yating Zhan \n" "Language-Team: Chinese (Simplified) \n" "Language: zh_Hans\n" @@ -331,7 +331,7 @@ msgstr "从选择的实例偷取看到的 emoji。" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_subchain" msgid "This policy processes messages through an alternate pipeline when a given message matches certain criteria. All criteria are configured as a map of regular expressions to lists of policy modules." -msgstr "" +msgstr "此策略将会把满足特定条件的信息通过另一管线处理。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format From 6ba1b792596184cc1d37efffd55b30d56417602a Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Thu, 1 Aug 2024 07:52:28 +0000 Subject: [PATCH 019/212] Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (47 of 47 strings) Translation: Pleroma/Pleroma Backend (domain posix_errors) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-posix_errors/zh_Hans/ --- .../zh_Hans/LC_MESSAGES/posix_errors.po | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/priv/gettext/zh_Hans/LC_MESSAGES/posix_errors.po b/priv/gettext/zh_Hans/LC_MESSAGES/posix_errors.po index c486a5486..930f5db1e 100644 --- a/priv/gettext/zh_Hans/LC_MESSAGES/posix_errors.po +++ b/priv/gettext/zh_Hans/LC_MESSAGES/posix_errors.po @@ -8,9 +8,9 @@ ## to merge POT files into PO files. msgid "" msgstr "" -"PO-Revision-Date: 2022-07-22 19:00+0000\n" -"Last-Translator: Yating Zhan \n" -"Language-Team: Chinese (Simplified) \n" +"Language-Team: Chinese (Simplified) \n" "Language: zh_Hans\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -22,19 +22,19 @@ msgid "eperm" msgstr "不允许的操作" msgid "eacces" -msgstr "权限不够" +msgstr "拒绝访问" msgid "eagain" msgstr "资源暂时不可用" msgid "ebadf" -msgstr "坏的文件描述符" +msgstr "非法的文件描述符" msgid "ebadmsg" -msgstr "坏讯息" +msgstr "非法消息" msgid "ebusy" -msgstr "设备或资源忙" +msgstr "设备或资源繁忙" msgid "edeadlk" msgstr "避免了资源死锁" @@ -46,10 +46,10 @@ msgid "edquot" msgstr "超出了磁盘配额" msgid "eexist" -msgstr "文件存在" +msgstr "文件已存在" msgid "efault" -msgstr "坏地址" +msgstr "非法地址" msgid "efbig" msgstr "文件太大" @@ -61,7 +61,7 @@ msgid "eintr" msgstr "系统调用被中断" msgid "einval" -msgstr "不合法的参数" +msgstr "非法参数" msgid "eio" msgstr "输入/输出错误" @@ -79,7 +79,7 @@ msgid "emlink" msgstr "太多链接" msgid "emultihop" -msgstr "" +msgstr "已尝试多跳" msgid "enametoolong" msgstr "文件名太长" @@ -97,7 +97,7 @@ msgid "enolck" msgstr "没有可用的锁" msgid "enolink" -msgstr "链接被切断了" +msgstr "链接被切断" msgid "enoent" msgstr "没这文件或目录" @@ -109,19 +109,19 @@ msgid "enospc" msgstr "设备上没剩余空间" msgid "enosr" -msgstr "" +msgstr "流资源不足" msgid "enostr" msgstr "设备不是流" msgid "enosys" -msgstr "功能没实现" +msgstr "功能未实现" msgid "enotblk" -msgstr "" +msgstr "需要块设备" msgid "enotdir" -msgstr "" +msgstr "不是目录" msgid "enotsup" msgstr "不受支持的操作" @@ -136,25 +136,25 @@ msgid "eoverflow" msgstr "请为给定类型的数据指定较小的数值" msgid "epipe" -msgstr "" +msgstr "管道中断" msgid "erange" -msgstr "" +msgstr "数值超过范围" msgid "erofs" -msgstr "只读权限文件系统" +msgstr "只读文件系统" msgid "espipe" -msgstr "" +msgstr "非法搜寻" msgid "esrch" -msgstr "具体进程不存在" +msgstr "进程不存在" msgid "estale" -msgstr "" +msgstr "过时的文件句柄" msgid "etxtbsy" -msgstr "文本文件忙碌" +msgstr "文本文件繁忙" msgid "exdev" -msgstr "该多设备链接不可用" +msgstr "非法多设备链接" From 73c6d7eaeb368d18a5624e9668f9a8c9842dfa58 Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Thu, 1 Aug 2024 07:47:25 +0000 Subject: [PATCH 020/212] Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (34 of 34 strings) Translation: Pleroma/Pleroma Backend (domain default) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-default/zh_Hans/ --- priv/gettext/zh_Hans/LC_MESSAGES/default.po | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/priv/gettext/zh_Hans/LC_MESSAGES/default.po b/priv/gettext/zh_Hans/LC_MESSAGES/default.po index ed0d1576b..ed98dfbd3 100644 --- a/priv/gettext/zh_Hans/LC_MESSAGES/default.po +++ b/priv/gettext/zh_Hans/LC_MESSAGES/default.po @@ -8,9 +8,9 @@ ## to merge POT files into PO files. msgid "" msgstr "" -"PO-Revision-Date: 2022-07-22 19:00+0000\n" -"Last-Translator: Yating Zhan \n" -"Language-Team: Chinese (Simplified) \n" +"Language-Team: Chinese (Simplified) \n" "Language: zh_Hans\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -106,7 +106,7 @@ msgstr "转换到 %{polymorphic_type} 中的任一 schema 失败" #: lib/pleroma/web/api_spec/render_error.ex:71 #, elixir-format msgid "Failed to cast value as %{invalid_schema}. Value must be castable using `allOf` schemas listed." -msgstr "把值转换成 %{invalid_schema} 失败。值必须可以被转换成在列的「所有」schema。" +msgstr "把值转换成 %{invalid_schema} 失败。值必须可以被转换成在列的 `allOf` schema。" #: lib/pleroma/web/api_spec/render_error.ex:84 #, elixir-format @@ -136,17 +136,17 @@ msgstr "缺少头:%{name}。" #: lib/pleroma/web/api_spec/render_error.ex:196 #, elixir-format msgid "No value provided for required discriminator `%{field}`." -msgstr "" +msgstr "没有提供给鉴别器 `%{field}` 提供所需要的值。" #: lib/pleroma/web/api_spec/render_error.ex:216 #, elixir-format msgid "Object property count %{property_count} is greater than maxProperties: %{max_properties}." -msgstr "" +msgstr "对象属性数 %{property_count} 大于 maxProperties: %{max_properties}。" #: lib/pleroma/web/api_spec/render_error.ex:224 #, elixir-format msgid "Object property count %{property_count} is less than minProperties: %{min_properties}" -msgstr "" +msgstr "对象属性数 %{property_count} 小于 minProperties: %{min_properties}。" #: lib/pleroma/web/templates/static_fe/static_fe/error.html.eex:2 #, elixir-format @@ -166,7 +166,7 @@ msgstr "未知的 schema:%{name}。" #: lib/pleroma/web/api_spec/render_error.ex:192 #, elixir-format msgid "Value used as discriminator for `%{field}` matches no schemas." -msgstr "" +msgstr "用于 `%{field}` 鉴别器的值无法匹配到任何 schema。" #: lib/pleroma/web/templates/embed/show.html.eex:43 #: lib/pleroma/web/templates/static_fe/static_fe/_notice.html.eex:37 From 030be7130773d5b50087b7ada6c62ce582fc008c Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Thu, 1 Aug 2024 07:11:09 +0000 Subject: [PATCH 021/212] Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (100 of 100 strings) Translation: Pleroma/Pleroma Backend (domain errors) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-errors/zh_Hans/ --- priv/gettext/zh_Hans/LC_MESSAGES/errors.po | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/priv/gettext/zh_Hans/LC_MESSAGES/errors.po b/priv/gettext/zh_Hans/LC_MESSAGES/errors.po index 4431445e3..668472939 100644 --- a/priv/gettext/zh_Hans/LC_MESSAGES/errors.po +++ b/priv/gettext/zh_Hans/LC_MESSAGES/errors.po @@ -3,9 +3,9 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2020-09-20 13:18+0000\n" -"PO-Revision-Date: 2022-07-22 19:00+0000\n" -"Last-Translator: Yating Zhan \n" -"Language-Team: Chinese (Simplified) \n" +"Language-Team: Chinese (Simplified) \n" "Language: zh_Hans\n" "MIME-Version: 1.0\n" @@ -392,7 +392,7 @@ msgid "Invalid answer data" msgstr "无效的回答数据" #: lib/pleroma/web/nodeinfo/nodeinfo_controller.ex:33 -#, elixir-format, fuzzy +#, elixir-format msgid "Nodeinfo schema version not handled" msgstr "Nodeinfo schema 版本没被处理" From 914fdc508def533e9fd1609f7c52202c58a5ea75 Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Thu, 1 Aug 2024 08:17:35 +0000 Subject: [PATCH 022/212] Translated using Weblate (Chinese (Simplified)) Currently translated at 19.8% (193 of 974 strings) Translation: Pleroma/Pleroma Backend (domain config_descriptions) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-config_descriptions/zh_Hans/ --- priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po b/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po index 808dd8bdc..c880c1e5e 100644 --- a/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po +++ b/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po @@ -3,8 +3,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-07-21 04:21+0300\n" -"PO-Revision-Date: 2024-08-01 08:17+0000\n" -"Last-Translator: Yating Zhan \n" +"PO-Revision-Date: 2024-08-01 08:19+0000\n" +"Last-Translator: Eric Zhang \n" "Language-Team: Chinese (Simplified) \n" "Language: zh_Hans\n" @@ -331,7 +331,8 @@ msgstr "从选择的实例偷取看到的 emoji。" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_subchain" msgid "This policy processes messages through an alternate pipeline when a given message matches certain criteria. All criteria are configured as a map of regular expressions to lists of policy modules." -msgstr "此策略将会把满足特定条件的信息通过另一管线处理。" +msgstr "此策略将会把满足特定条件的信息通过另一管线处理。所有条件都以正则表达式来对应" +"列出的策略模块配置。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format From 7b979ac09d6caf7e06100574ca79cc9e5438bfb5 Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Thu, 1 Aug 2024 07:13:57 +0000 Subject: [PATCH 023/212] Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (50 of 50 strings) Translation: Pleroma/Pleroma Backend (domain oauth_scopes) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-oauth_scopes/zh_Hans/ --- .../zh_Hans/LC_MESSAGES/oauth_scopes.po | 111 +++++++++--------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/priv/gettext/zh_Hans/LC_MESSAGES/oauth_scopes.po b/priv/gettext/zh_Hans/LC_MESSAGES/oauth_scopes.po index f2acac90a..204414836 100644 --- a/priv/gettext/zh_Hans/LC_MESSAGES/oauth_scopes.po +++ b/priv/gettext/zh_Hans/LC_MESSAGES/oauth_scopes.po @@ -3,14 +3,16 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-08-01 10:12+0300\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" +"PO-Revision-Date: 2024-08-01 08:19+0000\n" +"Last-Translator: Eric Zhang \n" +"Language-Team: Chinese (Simplified) \n" "Language: zh_Hans\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Translate Toolkit 3.7.2\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 4.13.1\n" ## This file is a PO Template file. ## @@ -21,253 +23,252 @@ msgstr "" ## Run "mix gettext.extract" to bring this file up to ## date. Leave "msgstr"s empty as changing them here has no ## effect: edit them in PO (.po) files instead. - #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin" -msgstr "" +msgstr "全部管理员权限" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:read" -msgstr "" +msgstr "使用管理员 API 读取" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:write" -msgstr "" +msgstr "使用管理员 API 写入" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "follow" -msgstr "" +msgstr "读取并写入用户关系" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read" -msgstr "" +msgstr "读取任何信息" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:accounts" -msgstr "" +msgstr "读取所有账号的信息" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:blocks" -msgstr "" +msgstr "读取块关系" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:bookmarks" -msgstr "" +msgstr "读取您的书签" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:favourites" -msgstr "" +msgstr "读取您喜欢的帖子" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:filters" -msgstr "" +msgstr "读取您的过滤器设置" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:follows" -msgstr "" +msgstr "读取关注关系" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:lists" -msgstr "" +msgstr "读取您的列表" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:notifications" -msgstr "" +msgstr "读取您的通知" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:search" -msgstr "" +msgstr "执行搜索" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:statuses" -msgstr "" +msgstr "读取您可以看到的动态" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write" -msgstr "" +msgstr "写入任何信息" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:accounts" -msgstr "" +msgstr "更改您的账号信息" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:blocks" -msgstr "" +msgstr "屏蔽或取消屏蔽任何人" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:bookmarks" -msgstr "" +msgstr "从您的书签中添加或移除" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:conversations" -msgstr "" +msgstr "更改收件人,标记为已阅,或删除聊天" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:favourites" -msgstr "" +msgstr "喜欢或取消喜欢动态" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:filters" -msgstr "" +msgstr "更改您的过滤器设置" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:follows" -msgstr "" +msgstr "关注或取消关注任何人" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:lists" -msgstr "" +msgstr "创建,更改或删除您的列表" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:media" -msgstr "" +msgstr "上传媒体文件或更改您上传的媒体文件" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:mutes" -msgstr "" +msgstr "隐藏或取消隐藏任何人" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:notifications" -msgstr "" +msgstr "标记通知为已读" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:statuses" -msgstr "" +msgstr "发表,编辑,转发帖子或对帖子做出回应" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:read:accounts" -msgstr "" +msgstr "使用管理员 API 读取所有账号" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:read:chats" -msgstr "" +msgstr "使用管理员 API 读取所有聊天" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:read:invites" -msgstr "" +msgstr "使用管理员 API 读取所有邀请码" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:read:media_proxy_caches" -msgstr "" +msgstr "使用管理员 API 读取媒体代理缓存" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:read:reports" -msgstr "" +msgstr "使用管理员 API 读取所有举报" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:read:statuses" -msgstr "" +msgstr "使用管理员 API 读取所有动态" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:write:accounts" -msgstr "" +msgstr "使用管理员 API 更改所有账号" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:write:chats" -msgstr "" +msgstr "使用管理员 API 更改所有聊天" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:write:follows" -msgstr "" +msgstr "使用管理员 API 更改关注关系" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:write:invites" -msgstr "" +msgstr "使用管理员 API 创建或吊销邀请码" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:write:media_proxy_caches" -msgstr "" +msgstr "使用管理员 API 更改媒体代理缓存" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:write:reports" -msgstr "" +msgstr "使用管理员 API 处理举报" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "admin:write:statuses" -msgstr "" +msgstr "使用管理员 API 删除动态,更改动态的范围,或标记为敏感动态" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:mutes" -msgstr "" +msgstr "读取隐藏关系" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "push" -msgstr "" +msgstr "推送通知" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:backups" -msgstr "" +msgstr "读取您的备份" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:chats" -msgstr "" +msgstr "读取您的聊天" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:media" -msgstr "" +msgstr "读取媒体附件" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "read:reports" -msgstr "" +msgstr "读取您的举报" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:chats" -msgstr "" +msgstr "添加或移除聊天信息,或者标记它们为已阅" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:follow" -msgstr "" +msgstr "关注或取消关注任何人" #: lib/pleroma/web/api_spec/scopes/translator.ex:5 #, elixir-autogen, elixir-format msgid "write:reports" -msgstr "" +msgstr "提交举报" From 9e3fa8924332a6ef1217b3ead2cd94c5b5ac1b4b Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Thu, 1 Aug 2024 08:20:04 +0000 Subject: [PATCH 024/212] Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (34 of 34 strings) Translation: Pleroma/Pleroma Backend (domain default) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-default/zh_Hans/ --- priv/gettext/zh_Hans/LC_MESSAGES/default.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/priv/gettext/zh_Hans/LC_MESSAGES/default.po b/priv/gettext/zh_Hans/LC_MESSAGES/default.po index ed98dfbd3..7d5828fae 100644 --- a/priv/gettext/zh_Hans/LC_MESSAGES/default.po +++ b/priv/gettext/zh_Hans/LC_MESSAGES/default.po @@ -8,7 +8,7 @@ ## to merge POT files into PO files. msgid "" msgstr "" -"PO-Revision-Date: 2024-08-01 08:19+0000\n" +"PO-Revision-Date: 2024-08-02 09:02+0000\n" "Last-Translator: Eric Zhang \n" "Language-Team: Chinese (Simplified) \n" @@ -146,7 +146,7 @@ msgstr "对象属性数 %{property_count} 大于 maxProperties: %{max_properties #: lib/pleroma/web/api_spec/render_error.ex:224 #, elixir-format msgid "Object property count %{property_count} is less than minProperties: %{min_properties}" -msgstr "对象属性数 %{property_count} 小于 minProperties: %{min_properties}。" +msgstr "对象属性数 %{property_count} 小于 minProperties: %{min_properties}" #: lib/pleroma/web/templates/static_fe/static_fe/error.html.eex:2 #, elixir-format From 16c6942df90391894e4c6eb0de6b9bffc7d8c30e Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Thu, 1 Aug 2024 08:44:57 +0000 Subject: [PATCH 025/212] Translated using Weblate (Chinese (Simplified)) Currently translated at 28.1% (274 of 974 strings) Translation: Pleroma/Pleroma Backend (domain config_descriptions) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-config_descriptions/zh_Hans/ --- .../LC_MESSAGES/config_descriptions.po | 201 +++++++++--------- 1 file changed, 105 insertions(+), 96 deletions(-) diff --git a/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po b/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po index c880c1e5e..a56c90724 100644 --- a/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po +++ b/priv/gettext/zh_Hans/LC_MESSAGES/config_descriptions.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-07-21 04:21+0300\n" -"PO-Revision-Date: 2024-08-01 08:19+0000\n" +"PO-Revision-Date: 2024-08-02 09:02+0000\n" "Last-Translator: Eric Zhang \n" "Language-Team: Chinese (Simplified) \n" @@ -140,7 +140,7 @@ msgstr "鉴权/授权设置" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:connections_pool" msgid "Advanced settings for `Gun` connections pool" -msgstr "「Gun」连接池的高级设置" +msgstr "`Gun` 连接池的高级设置" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -185,7 +185,7 @@ msgstr "Gopher 设置" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:hackney_pools" msgid "Advanced settings for `Hackney` connections pools" -msgstr "「Hackney」连接池的高级设置" +msgstr "`Hackney` 连接池的高级设置" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -328,7 +328,7 @@ msgid "Steals emojis from selected instances when it sees them." msgstr "从选择的实例偷取看到的 emoji。" #: lib/pleroma/docs/translator.ex:5 -#, elixir-autogen, elixir-format +#, elixir-autogen, elixir-format, fuzzy msgctxt "config description at :pleroma-:mrf_subchain" msgid "This policy processes messages through an alternate pipeline when a given message matches certain criteria. All criteria are configured as a map of regular expressions to lists of policy modules." msgstr "此策略将会把满足特定条件的信息通过另一管线处理。所有条件都以正则表达式来对应" @@ -350,13 +350,13 @@ msgstr "配置 OAuth 2 提供者的能力" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:pools" msgid "Advanced settings for `Gun` workers pools" -msgstr "「Gun」工人池的高级设置" +msgstr "`Gun` worker 池的高级设置" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:populate_hashtags_table" msgid "`populate_hashtags_table` background migration settings" -msgstr "「populate_hashtags_table」后台迁移设置" +msgstr "`populate_hashtags_table` 后台迁移设置" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -398,13 +398,13 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:uri_schemes" msgid "URI schemes related settings" -msgstr "" +msgstr "URI scheme 相关设置" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:web_cache_ttl" msgid "The expiration time for the web responses cache. Values should be in milliseconds or `nil` to disable expiration." -msgstr "web 回应缓存的过期时间。值应该以毫秒为单位,或者用「nil」来禁用过期。" +msgstr "网页回应缓存的过期时间。以毫秒为单位,或者用 `nil` 来禁用过期。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -416,7 +416,7 @@ msgstr "欢迎讯息设置" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:workers" msgid "Includes custom worker options not interpretable directly by `Oban`" -msgstr "包含不能直接被「Oban」解读的自定工人选项" +msgstr "包含不能直接被 `Oban` 解读的自定 worker 选项" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -491,7 +491,7 @@ msgstr "过滤器将会匿名化上传文件的文件名" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-Pleroma.Upload.Filter.Mogrify" msgid "Uploads mogrify filter settings" -msgstr "" +msgstr "morgify 上传过滤器设置" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -534,6 +534,9 @@ msgstr "元数据相关设定" msgctxt "config description at :pleroma-Pleroma.Web.Plugs.RemoteIp" msgid "`Pleroma.Web.Plugs.RemoteIp` is a shim to call [`RemoteIp`](https://git.pleroma.social/pleroma/remote_ip) but with runtime configuration.\n**If your instance is not behind at least one reverse proxy, you should not enable this plug.**\n" msgstr "" +"`Pleroma.Web.Plugs.RemoteIp` 是一个呼叫 [`RemoteIp`](https://git.pleroma." +"social/pleroma/remote_ip) 的 shim 但是包含运行库配置。\n" +"**如果您的实例不在至少一个反向代理后面,您不应该启用这个插件。**\n" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -551,13 +554,13 @@ msgstr "失效活动设定" #, elixir-autogen, elixir-format msgctxt "config description at :prometheus-Pleroma.Web.Endpoint.MetricsExporter" msgid "Prometheus app metrics endpoint configuration" -msgstr "" +msgstr "Prometheus 服务监控端点配置" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :web_push_encryption-:vapid_details" msgid "Web Push Notifications configuration. You can use the mix task mix web_push.gen.keypair to generate it." -msgstr "" +msgstr "网页推送通知配置。您可以使用 mix task mix web_push.gen.keypair 来生成它。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -587,7 +590,7 @@ msgstr "ActivityPub" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:assets" msgid "Assets" -msgstr "" +msgstr "资源" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -647,7 +650,7 @@ msgstr "Gopher" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:hackney_pools" msgid "Hackney pools" -msgstr "" +msgstr "Hackney 池" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -677,25 +680,25 @@ msgstr "实例图标" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:ldap" msgid "LDAP" -msgstr "" +msgstr "LDAP" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:majic_pool" msgid "Majic pool" -msgstr "" +msgstr "Majic 池" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:manifest" msgid "Manifest" -msgstr "" +msgstr "Manifest" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:markup" msgid "Markup Settings" -msgstr "" +msgstr "标记设置" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -713,25 +716,25 @@ msgstr "媒体文件代理" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:modules" msgid "Modules" -msgstr "" +msgstr "模块" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf" msgid "MRF" -msgstr "" +msgstr "MRF" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_activity_expiration" msgid "MRF Activity Expiration Policy" -msgstr "" +msgstr "MRF 活动过期策略" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_follow_bot" msgid "MRF FollowBot Policy" -msgstr "" +msgstr "MRF FollowBot 策略" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -743,49 +746,49 @@ msgstr "MRF 标签" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_hellthread" msgid "MRF Hellthread" -msgstr "" +msgstr "MRF Hellthread" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_keyword" msgid "MRF Keyword" -msgstr "" +msgstr "MRF 关键词" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_mention" msgid "MRF Mention" -msgstr "" +msgstr "MRF 提及" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_normalize_markup" msgid "MRF Normalize Markup" -msgstr "" +msgstr "MRF 标记标准化" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_object_age" msgid "MRF Object Age" -msgstr "" +msgstr "MRF 对象年龄" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_rejectnonpublic" msgid "MRF Reject Non Public" -msgstr "" +msgstr "MRF 拒绝非公开帖子" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_simple" msgid "MRF Simple" -msgstr "" +msgstr "MRF 简单" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:mrf_steal_emoji" msgid "MRF Emojis" -msgstr "" +msgstr "MRF 表情符号" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -803,13 +806,13 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:oauth2" msgid "OAuth2" -msgstr "" +msgstr "OAuth2" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:pools" msgid "Pools" -msgstr "" +msgstr "池" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -821,13 +824,13 @@ msgstr "本站话题标签列表" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:rate_limit" msgid "Rate limit" -msgstr "" +msgstr "限流" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:restrict_unauthenticated" msgid "Restrict Unauthenticated" -msgstr "" +msgstr "限制未授权用户" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -845,7 +848,7 @@ msgstr "留言板" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:static_fe" msgid "Static FE" -msgstr "" +msgstr "静态 FE" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -857,7 +860,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:uri_schemes" msgid "URI Schemes" -msgstr "" +msgstr "URI Schemes" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -869,7 +872,7 @@ msgstr "用户" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:web_cache_ttl" msgid "Web cache TTL" -msgstr "" +msgstr "网页缓存 TTL" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -881,13 +884,13 @@ msgstr "欢迎" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-:workers" msgid "Workers" -msgstr "工人" +msgstr "Workers" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-ConcurrentLimiter" msgid "ConcurrentLimiter" -msgstr "" +msgstr "ConcurrentLimiter" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -899,133 +902,133 @@ msgstr "Oban" #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Captcha" msgid "Pleroma.Captcha" -msgstr "" +msgstr "Pleroma.Captcha" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Captcha.Kocaptcha" msgid "Pleroma.Captcha.Kocaptcha" -msgstr "" +msgstr "Pleroma.Captcha.Kocaptcha" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Emails.Mailer" msgid "Pleroma.Emails.Mailer" -msgstr "" +msgstr "Pleroma.Emails.Mailer" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Emails.NewUsersDigestEmail" msgid "Pleroma.Emails.NewUsersDigestEmail" -msgstr "" +msgstr "Pleroma.Emails.NewUsersDigestEmail" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Emails.UserEmail" msgid "Pleroma.Emails.UserEmail" -msgstr "" +msgstr "Pleroma.Emails.UserEmail" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Formatter" msgid "Linkify" -msgstr "" +msgstr "Linkify" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.ScheduledActivity" msgid "Pleroma.ScheduledActivity" -msgstr "" +msgstr "Pleroma.ScheduledActivity" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Upload" msgid "Pleroma.Upload" -msgstr "" +msgstr "Pleroma.Upload" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Upload.Filter.AnonymizeFilename" msgid "Pleroma.Upload.Filter.AnonymizeFilename" -msgstr "" +msgstr "Pleroma.Upload.Filter.AnonymizeFilename" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Upload.Filter.Mogrify" msgid "Pleroma.Upload.Filter.Mogrify" -msgstr "" +msgstr "Pleroma.Upload.Filter.Mogrify" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Uploaders.Local" msgid "Pleroma.Uploaders.Local" -msgstr "" +msgstr "Pleroma.Uploaders.Local" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Uploaders.S3" msgid "Pleroma.Uploaders.S3" -msgstr "" +msgstr "Pleroma.Uploaders.S3" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.User" msgid "Pleroma.User" -msgstr "" +msgstr "Pleroma.User" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.User.Backup" msgid "Pleroma.User.Backup" -msgstr "" +msgstr "Pleroma.User.Backup" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Web.ApiSpec.CastAndValidate" msgid "Pleroma.Web.ApiSpec.CastAndValidate" -msgstr "" +msgstr "Pleroma.Web.ApiSpec.CastAndValidate" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Web.MediaProxy.Invalidation.Http" msgid "Pleroma.Web.MediaProxy.Invalidation.Http" -msgstr "" +msgstr "Pleroma.Web.MediaProxy.Invalidation.Http" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Web.MediaProxy.Invalidation.Script" msgid "Pleroma.Web.MediaProxy.Invalidation.Script" -msgstr "" +msgstr "Pleroma.Web.MediaProxy.Invalidation.Script" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Web.Metadata" msgid "Pleroma.Web.Metadata" -msgstr "" +msgstr "Pleroma.Web.Metadata" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Web.Plugs.RemoteIp" msgid "Pleroma.Web.Plugs.RemoteIp" -msgstr "" +msgstr "Pleroma.Web.Plugs.RemoteIp" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Web.Preload" msgid "Pleroma.Web.Preload" -msgstr "" +msgstr "Pleroma.Web.Preload" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma-Pleroma.Workers.PurgeExpiredActivity" msgid "Pleroma.Workers.PurgeExpiredActivity" -msgstr "" +msgstr "Pleroma.Workers.PurgeExpiredActivity" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :prometheus-Pleroma.Web.Endpoint.MetricsExporter" msgid "Pleroma.Web.Endpoint.MetricsExporter" -msgstr "" +msgstr "Pleroma.Web.Endpoint.MetricsExporter" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1067,37 +1070,39 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :ex_aws-:s3 > :access_key_id" msgid "S3 access key ID" -msgstr "" +msgstr "S3 访问密钥 ID" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :ex_aws-:s3 > :host" msgid "S3 host" -msgstr "" +msgstr "S3 主机" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :ex_aws-:s3 > :region" msgid "S3 region (for AWS)" -msgstr "" +msgstr "S3 区域(AWS)" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :ex_aws-:s3 > :secret_access_key" msgid "Secret access key" -msgstr "" +msgstr "访问密钥" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :logger > :backends" msgid "Where logs will be sent, :console - send logs to stdout, { ExSyslogger, :ex_syslogger } - to syslog, Quack.Logger - to Slack." msgstr "" +"日志发送的地点,:console - 将日志发送到 stdout, { ExSyslogger, :ex_syslogger " +"} - 发送到 syslog, Quack.Logger - 发送到 Slack." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :logger-:console > :format" msgid "Default: \"$date $time [$level] $levelpad$node $metadata $message\"" -msgstr "" +msgstr "默认:\"$date $time [$level] $levelpad$node $metadata $message\"" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1109,13 +1114,13 @@ msgstr "日志等级" #, elixir-autogen, elixir-format msgctxt "config description at :logger-:ex_syslogger > :format" msgid "Default: \"$date $time [$level] $levelpad$node $metadata $message\"" -msgstr "" +msgstr "默认:\"$date $time [$level] $levelpad$node $metadata $message\"" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :logger-:ex_syslogger > :ident" msgid "A string that's prepended to every message, and is typically set to the app name" -msgstr "" +msgstr "注入在每一个消息前面的字符串,通常设为应用程序的名称" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1151,13 +1156,13 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:activitypub > :outgoing_blocks" msgid "Whether to federate blocks to other instances" -msgstr "" +msgstr "是否与其他实例同步屏蔽列表" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:activitypub > :sign_object_fetches" msgid "Sign object fetches with HTTP signatures" -msgstr "" +msgstr "为对象获取进行 HTTP 签名" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1169,7 +1174,7 @@ msgstr "屏蔽对象时是否同时取消对其的关注" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:assets > :default_mascot" msgid "This will be used as the default mascot on MastoFE. Default: `:pleroma_fox_tan`" -msgstr "" +msgstr "这将是 MastoFE 的默认吉祥物。默认:`:pleroma_fox_tan`" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1181,13 +1186,15 @@ msgstr "默认用户头像的网址" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:assets > :mascots" msgid "Keyword of mascots, each element must contain both an URL and a mime_type key" -msgstr "" +msgstr "吉祥物关键词,每一个元素必须包含一个 URL 和 mine_type 值" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:auth > :auth_template" msgid "Authentication form template. By default it's `show.html` which corresponds to `lib/pleroma/web/templates/o_auth/o_auth/show.html.ee`." msgstr "" +"授权表达模板。默认是 `show.html`,对应于 `lib/pleroma/web/templates/o_auth/" +"o_auth/show.html.ee`。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1211,7 +1218,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:connections_pool > :connect_timeout" msgid "Timeout while `gun` will wait until connection is up. Default: 5000ms." -msgstr "「Gun」等待连接时触发超时的上限。默认为5000ms。" +msgstr "`gun` 等待连接时触发超时的上限。默认:5000ms。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1229,7 +1236,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:connections_pool > :max_connections" msgid "Maximum number of connections in the pool. Default: 250 connections." -msgstr "" +msgstr "池的最大连接数量。默认:250 连接。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1265,13 +1272,15 @@ msgstr "单个用户每次收到摘要邮件的间隔" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:email_notifications > :digest > :schedule" msgid "When to send digest email, in crontab format. \"0 0 0\" is the default, meaning \"once a week at midnight on Sunday morning\"." -msgstr "" +msgstr "发送摘要邮件的时间,以 crontab 格式。默认为“0 0 " +"0”,意味着“每周在周日的午夜时分”。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:emoji > :default_manifest" msgid "Location of the JSON-manifest. This manifest contains information about the emoji-packs you can download. Currently only one manifest can be added (no arrays)." -msgstr "" +msgstr "JSON-manifest 的位置。manifest 包含您可以下载的表情包信息。目前只能添加一个 " +"manifest(无数列)。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1289,7 +1298,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:emoji > :shortcode_globs" msgid "Location of custom emoji files. * can be used as a wildcard." -msgstr "" +msgstr "自定义表情符号位置。* 可以当作通配符使用。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1301,7 +1310,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:feed > :post_title" msgid "Configure title rendering" -msgstr "" +msgstr "配置标题渲染" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1471,7 +1480,7 @@ msgstr "管理员前端" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:frontends > :admin > name" msgid "Name of the installed frontend. Valid config must include both `Name` and `Reference` values." -msgstr "已安装的前端名称。只有包含了「名称」与「引用」数值才能被算作有效配置。" +msgstr "已安装的前端名称。有效配置必须包含 `Name` 和 `Reference` 数值。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1501,13 +1510,13 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:frontends > :available > custom-http-headers" msgid "The custom HTTP headers for the frontend" -msgstr "" +msgstr "前端的自定义 HTTP 响应头" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:frontends > :available > git" msgid "URL of the git repository of the frontend" -msgstr "" +msgstr "前端 git 仓库的 URL" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1525,13 +1534,13 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:frontends > :primary" msgid "Primary frontend, the one that is served for all pages by default" -msgstr "" +msgstr "主要前端,这是默认服务所有页面的前端" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:frontends > :primary > name" msgid "Name of the installed frontend. Valid config must include both `Name` and `Reference` values." -msgstr "已安装的前端名称。只有包含了「名称」与「引用」数值才能被算作有效配置。" +msgstr "已安装的前端名称。有效配置必须包含 `Name` 和 `Reference` 数值。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1543,7 +1552,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:gopher > :dstport" msgid "Port advertised in URLs (optional, defaults to port)" -msgstr "" +msgstr "URL 中宣传的端口(可选,默认为端口)" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1573,7 +1582,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:hackney_pools > :federation > :max_connections" msgid "Number workers in the pool." -msgstr "池内的工人数量。" +msgstr "池内的 worker 数量。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1591,7 +1600,7 @@ msgstr "媒体池设定。" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:hackney_pools > :media > :max_connections" msgid "Number workers in the pool." -msgstr "池内的工人数量。" +msgstr "池内的 worker 数量。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1603,7 +1612,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:hackney_pools > :upload" msgid "Settings for upload pool." -msgstr "" +msgstr "上传池设置。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1783,7 +1792,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:instance > :email" msgid "Email used to reach an Administrator/Moderator of the instance" -msgstr "" +msgstr "用于联系实例管理员/监管员的电子邮箱" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1816,10 +1825,10 @@ msgid "Timeout (in days) of each external federation target being unreachable pr msgstr "" #: lib/pleroma/docs/translator.ex:5 -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:instance > :healthcheck" msgid "If enabled, system data will be shown on `/api/pleroma/healthcheck`" -msgstr "若启用,「/api/pleroma/healthcheck」下将显示系统数据" +msgstr "若启用,`/api/pleroma/healthcheck` 下将显示系统数据" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1831,7 +1840,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:instance > :invites_enabled" msgid "Enable user invitations for admins (depends on `registrations_open` being disabled)" -msgstr "只有管理员邀请的用户方能注册(需要关闭「registrations_open」选项)" +msgstr "只有管理员邀请的用户才能注册(需要关闭 `registrations_open` 选项)" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1903,13 +1912,13 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:instance > :multi_factor_authentication > :backup_codes > :number" msgid "Number of backup codes to generate." -msgstr "" +msgstr "生成的备份密钥数目。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:instance > :multi_factor_authentication > :totp" msgid "TOTP settings" -msgstr "" +msgstr "TOTP 设置" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -1976,7 +1985,7 @@ msgstr "允许管理员访问敏感信息(例,更新用户凭据、取得密 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:instance > :profile_directory" msgid "Enable profile directory." -msgstr "" +msgstr "启用用户主页配置。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -2023,10 +2032,10 @@ msgstr "" "用户(例,“@admin 请留意 @bad_actor”)。默认下为关闭状态" #: lib/pleroma/docs/translator.ex:5 -#, elixir-autogen, elixir-format, fuzzy +#, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:instance > :show_reactions" msgid "Let favourites and emoji reactions be viewed through the API." -msgstr "允许通过此API来看见喜欢数量与表情反应。" +msgstr "允许通过此 API 来看见喜欢数量与表情回应。" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format From 5f6506d864239408e9fa3705c5dd7b241307241a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 27 Aug 2024 20:39:32 -0400 Subject: [PATCH 026/212] Pleroma.HTTP: option stream: true will return a stream as the body for Gun adapter --- lib/pleroma/http/adapter_helper/gun.ex | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index 1fe8dd4b2..f9a8180f2 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -32,6 +32,7 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do |> AdapterHelper.maybe_add_proxy(proxy) |> Keyword.merge(incoming_opts) |> put_timeout() + |> maybe_stream() end defp add_scheme_opts(opts, %{scheme: "http"}), do: opts @@ -47,6 +48,14 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do Keyword.put(opts, :timeout, recv_timeout) end + # Tesla Gun adapter uses body_as: :stream + defp maybe_stream(opts) do + case Keyword.pop(opts, :stream, nil) do + {true, opts} -> Keyword.put(opts, :body_as, :stream) + {_, opts} -> opts + end + end + @spec pool_timeout(pool()) :: non_neg_integer() def pool_timeout(pool) do default = Config.get([:pools, :default, :recv_timeout], 5_000) From bb279c28025522764272468e3177a5f6701bc155 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 27 Aug 2024 21:08:25 -0400 Subject: [PATCH 027/212] Pleroma.HTTP add AdapterHelper.can_stream? to assist with discovering if the current adapter supports returning a Stream body --- lib/pleroma/http/adapter_helper.ex | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index dcb27a29d..f8bde2ac3 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -118,4 +118,13 @@ defmodule Pleroma.HTTP.AdapterHelper do host_charlist end end + + #TODO add Finch support once we have an AdapterHelper for it + @spec can_stream? :: bool() + def can_stream? do + case Application.get_env(:tesla, :adapter) do + Tesla.Adapter.Gun -> true + _ -> false + end + end end From ec8db9d4eedfade5a8b74425b21b07b3f4e44992 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 27 Aug 2024 21:09:15 -0400 Subject: [PATCH 028/212] RichMedia: skip the HTTP HEAD request for adapters that support streaming the response body --- lib/pleroma/web/rich_media/helpers.ex | 38 +++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index e2889b351..88bfbae68 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -11,16 +11,39 @@ defmodule Pleroma.Web.RichMedia.Helpers do @spec rich_media_get(String.t()) :: {:ok, String.t()} | get_errors() def rich_media_get(url) do - headers = [{"user-agent", Pleroma.Application.user_agent() <> "; Bot"}] + case Pleroma.HTTP.AdapterHelper.can_stream?() do + true -> stream(url) + false -> head_first(url) + end + |> handle_result(url) + end + defp stream(url) do + with {_, {:ok, %Tesla.Env{status: 200, body: stream_body, headers: headers}}} <- + {:head, Pleroma.HTTP.get(url, req_headers(), http_options())}, + {_, :ok} <- {:content_type, check_content_type(headers)}, + {_, :ok} <- {:content_length, check_content_length(headers)} do + body = Enum.into(stream_body, <<>>) + {:ok, body} + end + end + + defp head_first(url) do with {_, {:ok, %Tesla.Env{status: 200, headers: headers}}} <- - {:head, Pleroma.HTTP.head(url, headers, http_options())}, + {:head, Pleroma.HTTP.head(url, req_headers(), http_options())}, {_, :ok} <- {:content_type, check_content_type(headers)}, {_, :ok} <- {:content_length, check_content_length(headers)}, {_, {:ok, %Tesla.Env{status: 200, body: body}}} <- - {:get, Pleroma.HTTP.get(url, headers, http_options())} do + {:get, Pleroma.HTTP.get(url, req_headers(), http_options())} do {:ok, body} - else + end + end + + defp handle_result(result, url) do + case result do + {:ok, body} -> + {:ok, body} + {:head, _} -> Logger.debug("Rich media error for #{url}: HTTP HEAD failed") {:error, :head} @@ -74,7 +97,12 @@ defmodule Pleroma.Web.RichMedia.Helpers do [ pool: :rich_media, max_body: Config.get([:rich_media, :max_body], 5_000_000), - tesla_middleware: [{Tesla.Middleware.Timeout, timeout: timeout}] + tesla_middleware: [{Tesla.Middleware.Timeout, timeout: timeout}], + stream: true ] end + + defp req_headers do + [{"user-agent", Pleroma.Application.user_agent() <> "; Bot"}] + end end From 0a86d2b3ac9c90a16aec1237019ecfcb1e680728 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 27 Aug 2024 21:22:59 -0400 Subject: [PATCH 029/212] Handle streaming response errors --- lib/pleroma/http/adapter_helper.ex | 2 +- lib/pleroma/web/rich_media/helpers.ex | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index f8bde2ac3..4dbcccdcc 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -119,7 +119,7 @@ defmodule Pleroma.HTTP.AdapterHelper do end end - #TODO add Finch support once we have an AdapterHelper for it + # TODO add Finch support once we have an AdapterHelper for it @spec can_stream? :: bool() def can_stream? do case Application.get_env(:tesla, :adapter) do diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 88bfbae68..db1310b23 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 rich_media_get(String.t()) :: {:ok, String.t()} | get_errors() def rich_media_get(url) do - case Pleroma.HTTP.AdapterHelper.can_stream?() do - true -> stream(url) - false -> head_first(url) - end + case Pleroma.HTTP.AdapterHelper.can_stream?() do + true -> stream(url) + false -> head_first(url) + end |> handle_result(url) end @@ -22,8 +22,8 @@ defmodule Pleroma.Web.RichMedia.Helpers do with {_, {:ok, %Tesla.Env{status: 200, body: stream_body, headers: headers}}} <- {:head, Pleroma.HTTP.get(url, req_headers(), http_options())}, {_, :ok} <- {:content_type, check_content_type(headers)}, - {_, :ok} <- {:content_length, check_content_length(headers)} do - body = Enum.into(stream_body, <<>>) + {_, :ok} <- {:content_length, check_content_length(headers)}, + body <- Enum.into(stream_body, <<>>) do {:ok, body} end end @@ -59,6 +59,10 @@ defmodule Pleroma.Web.RichMedia.Helpers do {:get, _} -> Logger.debug("Rich media error for #{url}: HTTP GET failed") {:error, :get} + + {:error, :recv_chunk_timeout} -> + Logger.debug("Rich media error for #{url}: HTTP streaming response failed") + {:error, :get} end end From 116fe77b77eedd2feb073d3be256fea08169c95b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 27 Aug 2024 21:55:06 -0400 Subject: [PATCH 030/212] Tesla.Middleware.Timeout breaks streaming bodies These are executed by Oban now and Oban can enforce the timeout if the regular HTTP timeout is not sufficient. --- lib/pleroma/web/rich_media/helpers.ex | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index db1310b23..880d19218 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -96,12 +96,9 @@ defmodule Pleroma.Web.RichMedia.Helpers do end defp http_options do - timeout = Config.get!([:rich_media, :timeout]) - [ pool: :rich_media, max_body: Config.get([:rich_media, :max_body], 5_000_000), - tesla_middleware: [{Tesla.Middleware.Timeout, timeout: timeout}], stream: true ] end From 44901502ffd7713d498976e2d2b9a55c298f1876 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 27 Aug 2024 21:56:02 -0400 Subject: [PATCH 031/212] Fix incorrect identifier for the with statement --- lib/pleroma/web/rich_media/helpers.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index 880d19218..b81984343 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.RichMedia.Helpers do defp stream(url) do with {_, {:ok, %Tesla.Env{status: 200, body: stream_body, headers: headers}}} <- - {:head, Pleroma.HTTP.get(url, req_headers(), http_options())}, + {:get, Pleroma.HTTP.get(url, req_headers(), http_options())}, {_, :ok} <- {:content_type, check_content_type(headers)}, {_, :ok} <- {:content_length, check_content_length(headers)}, body <- Enum.into(stream_body, <<>>) do From 0804b73c0ae5846a133386c09970546375e3d918 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 27 Aug 2024 22:08:29 -0400 Subject: [PATCH 032/212] This error is not returned by Tesla Upstream has a bug filed for this as they aren't handling this error internally, so it was raising --- lib/pleroma/web/rich_media/helpers.ex | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index b81984343..a242ca640 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -59,10 +59,6 @@ defmodule Pleroma.Web.RichMedia.Helpers do {:get, _} -> Logger.debug("Rich media error for #{url}: HTTP GET failed") {:error, :get} - - {:error, :recv_chunk_timeout} -> - Logger.debug("Rich media error for #{url}: HTTP streaming response failed") - {:error, :get} end end From 3419e2cbdd3bf1c21e3bbf44ea5313ecfd03c989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 28 Aug 2024 17:37:42 +0200 Subject: [PATCH 033/212] Correct response in AdminAPI docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- changelog.d/docs-fix.skip | 0 docs/development/API/admin_api.md | 31 +++++++++++++++++-------------- 2 files changed, 17 insertions(+), 14 deletions(-) create mode 100644 changelog.d/docs-fix.skip diff --git a/changelog.d/docs-fix.skip b/changelog.d/docs-fix.skip new file mode 100644 index 000000000..e69de29bb diff --git a/docs/development/API/admin_api.md b/docs/development/API/admin_api.md index 5b373b8e1..409e78a1e 100644 --- a/docs/development/API/admin_api.md +++ b/docs/development/API/admin_api.md @@ -433,7 +433,7 @@ Response: * On success: URL of the unfollowed relay ```json -{"https://example.com/relay"} +"https://example.com/relay" ``` ## `POST /api/v1/pleroma/admin/users/invite_token` @@ -1193,20 +1193,23 @@ Loads json generated from `config/descriptions.exs`. - Response: ```json -[ - { - "id": 1234, - "data": { - "actor": { - "id": 1, - "nickname": "lain" +{ + "items": [ + { + "id": 1234, + "data": { + "actor": { + "id": 1, + "nickname": "lain" + }, + "action": "relay_follow" }, - "action": "relay_follow" - }, - "time": 1502812026, // timestamp - "message": "[2017-08-15 15:47:06] @nick0 followed relay: https://example.org/relay" // log message - } -] + "time": 1502812026, // timestamp + "message": "[2017-08-15 15:47:06] @nick0 followed relay: https://example.org/relay" // log message + } + ], + "total": 1 +} ``` ## `POST /api/v1/pleroma/admin/reload_emoji` From fc450fdefc2df2bbec20a79fb2c60a95e7f41833 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 15:45:13 -0400 Subject: [PATCH 034/212] ReceiverWorker: cancel job if user fetch is forbidden An instance block with authenticated fetch being required can cause this as we couldn't get the user to find their public key to verify the signature. Commonly observed if someone boosts/Announces a post from an instance that blocked you. --- lib/pleroma/workers/receiver_worker.ex | 5 +- test/pleroma/workers/receiver_worker_test.exs | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index d4db97b63..7dce02a5f 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -56,17 +56,20 @@ defmodule Pleroma.Workers.ReceiverWorker do def timeout(_job), do: :timer.seconds(5) + defp process_errors({:error, {:error, _} = error}), do: process_errors(error) + defp process_errors(errors) do case errors do {:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed} {:error, :already_present} -> {:cancel, :already_present} {:error, {:validate_object, _} = reason} -> {:cancel, reason} - {:error, {:error, {:validate, {:error, _changeset} = reason}}} -> {:cancel, reason} + {:error, {:validate, {:error, _changeset} = reason}} -> {:cancel, reason} {:error, {:reject, _} = reason} -> {:cancel, reason} {:signature, false} -> {:cancel, :invalid_signature} {:error, "Object has been deleted"} = reason -> {:cancel, reason} {:error, {:side_effects, {:error, :no_object_actor}} = reason} -> {:cancel, reason} {:error, :not_found} = reason -> {:cancel, reason} + {:error, :forbidden} = reason -> {:cancel, reason} {:error, _} = e -> e e -> {:error, e} end diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index 33be91085..640cefb78 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -51,6 +51,54 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do }) end + test "it does not retry if a user fetch fails with a 403" do + Tesla.Mock.mock(fn + %{url: "https://simpsons.com/users/bart"} -> + %Tesla.Env{ + status: 403, + body: "" + } + end) + + params = + %{ + "@context" => [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + "actor" => "https://simpsons.com/users/bart", + "cc" => [], + "id" => "https://simpsons.com/activity/eat-my-shorts", + "object" => %{}, + "to" => ["https://www.w3.org/ns/activitystreams#Public"], + "type" => "Create" + } + + req_headers = [ + ["accept-encoding", "gzip"], + ["content-length", "31337"], + ["content-type", "application/activity+json"], + ["date", "Wed, 28 Aug 2024 15:36:31 GMT"], + ["digest", "SHA-256=ouge/6HP2/QryG6F3JNtZ6vzs/hSwMk67xdxe87eH7A="], + ["host", "bikeshed.party"], + [ + "signature", + "does not matter as user needs to be fetched first" + ] + ] + + {:ok, oban_job} = + Federator.incoming_ap_doc(%{ + method: "POST", + req_headers: req_headers, + request_path: "/inbox", + params: params, + query_string: "" + }) + + assert {:cancel, {:error, :forbidden}} = ReceiverWorker.perform(oban_job) + end + test "it can validate the signature" do Tesla.Mock.mock(fn %{url: "https://mastodon.social/users/bastianallgeier"} -> From 60101e240dea53c3496eda548dbe269fc22b2f72 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 15:54:49 -0400 Subject: [PATCH 035/212] Add test confirming cancellation for activity by a deleted user --- test/pleroma/workers/receiver_worker_test.exs | 88 ++++++++++--------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index 640cefb78..2c0da8887 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -51,52 +51,56 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do }) end - test "it does not retry if a user fetch fails with a 403" do - Tesla.Mock.mock(fn - %{url: "https://simpsons.com/users/bart"} -> - %Tesla.Env{ - status: 403, - body: "" - } - end) + describe "cancels on a failed user fetch" do + setup do + Tesla.Mock.mock(fn + %{url: "https://springfield.social/users/bart"} -> + %Tesla.Env{ + status: 403, + body: "" + } - params = - %{ - "@context" => [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1" - ], - "actor" => "https://simpsons.com/users/bart", - "cc" => [], - "id" => "https://simpsons.com/activity/eat-my-shorts", - "object" => %{}, - "to" => ["https://www.w3.org/ns/activitystreams#Public"], - "type" => "Create" - } + %{url: "https://springfield.social/users/troymcclure"} -> + %Tesla.Env{ + status: 404, + body: "" + } + end) + end - req_headers = [ - ["accept-encoding", "gzip"], - ["content-length", "31337"], - ["content-type", "application/activity+json"], - ["date", "Wed, 28 Aug 2024 15:36:31 GMT"], - ["digest", "SHA-256=ouge/6HP2/QryG6F3JNtZ6vzs/hSwMk67xdxe87eH7A="], - ["host", "bikeshed.party"], - [ - "signature", - "does not matter as user needs to be fetched first" - ] - ] + test "when request returns a 403" do + params = + insert(:note_activity).data + |> Map.put("actor", "https://springfield.social/users/bart") - {:ok, oban_job} = - Federator.incoming_ap_doc(%{ - method: "POST", - req_headers: req_headers, - request_path: "/inbox", - params: params, - query_string: "" - }) + {:ok, oban_job} = + Federator.incoming_ap_doc(%{ + method: "POST", + req_headers: [], + request_path: "/inbox", + params: params, + query_string: "" + }) - assert {:cancel, {:error, :forbidden}} = ReceiverWorker.perform(oban_job) + assert {:cancel, {:error, :forbidden}} = ReceiverWorker.perform(oban_job) + end + + test "when request returns a 404" do + params = + insert(:note_activity).data + |> Map.put("actor", "https://springfield.social/users/troymcclure") + + {:ok, oban_job} = + Federator.incoming_ap_doc(%{ + method: "POST", + req_headers: [], + request_path: "/inbox", + params: params, + query_string: "" + }) + + assert {:cancel, {:error, :not_found}} = ReceiverWorker.perform(oban_job) + end end test "it can validate the signature" do From 66e1b4089528dcd5bcdb61343f111cea03f17ab8 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 16:04:12 -0400 Subject: [PATCH 036/212] Cancel if the User fetch resulted in a 410 --- test/pleroma/workers/receiver_worker_test.exs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index 2c0da8887..085108e37 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -65,6 +65,12 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do status: 404, body: "" } + + %{url: "https://springfield.social/users/hankscorpio"} -> + %Tesla.Env{ + status: 410, + body: "" + } end) end @@ -101,6 +107,23 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do assert {:cancel, {:error, :not_found}} = ReceiverWorker.perform(oban_job) end + + test "when request returns a 410" do + params = + insert(:note_activity).data + |> Map.put("actor", "https://springfield.social/users/hankscorpio") + + {:ok, oban_job} = + Federator.incoming_ap_doc(%{ + method: "POST", + req_headers: [], + request_path: "/inbox", + params: params, + query_string: "" + }) + + assert {:cancel, {:error, :not_found}} = ReceiverWorker.perform(oban_job) + end end test "it can validate the signature" do From 48a46618858c9b0dee5ade61c0d9113c521be289 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 16:22:38 -0400 Subject: [PATCH 037/212] Simplify test, move data into a json fixture By removing the inReplyTo, tags, and cc we can simplify the test and it still passes signature validation --- test/fixtures/bastianallgeier.json | 117 -------------- .../receiver_worker_signature_activity.json | 127 ++++++++++----- test/pleroma/workers/receiver_worker_test.exs | 147 +----------------- 3 files changed, 89 insertions(+), 302 deletions(-) delete mode 100644 test/fixtures/bastianallgeier.json diff --git a/test/fixtures/bastianallgeier.json b/test/fixtures/bastianallgeier.json deleted file mode 100644 index 6b47e7db9..000000000 --- a/test/fixtures/bastianallgeier.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - { - "Curve25519Key": "toot:Curve25519Key", - "Device": "toot:Device", - "Ed25519Key": "toot:Ed25519Key", - "Ed25519Signature": "toot:Ed25519Signature", - "EncryptedMessage": "toot:EncryptedMessage", - "PropertyValue": "schema:PropertyValue", - "alsoKnownAs": { - "@id": "as:alsoKnownAs", - "@type": "@id" - }, - "cipherText": "toot:cipherText", - "claim": { - "@id": "toot:claim", - "@type": "@id" - }, - "deviceId": "toot:deviceId", - "devices": { - "@id": "toot:devices", - "@type": "@id" - }, - "discoverable": "toot:discoverable", - "featured": { - "@id": "toot:featured", - "@type": "@id" - }, - "featuredTags": { - "@id": "toot:featuredTags", - "@type": "@id" - }, - "fingerprintKey": { - "@id": "toot:fingerprintKey", - "@type": "@id" - }, - "focalPoint": { - "@container": "@list", - "@id": "toot:focalPoint" - }, - "identityKey": { - "@id": "toot:identityKey", - "@type": "@id" - }, - "indexable": "toot:indexable", - "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", - "memorial": "toot:memorial", - "messageFranking": "toot:messageFranking", - "messageType": "toot:messageType", - "movedTo": { - "@id": "as:movedTo", - "@type": "@id" - }, - "publicKeyBase64": "toot:publicKeyBase64", - "schema": "http://schema.org#", - "suspended": "toot:suspended", - "toot": "http://joinmastodon.org/ns#", - "value": "schema:value" - } - ], - "attachment": [ - { - "name": "Website", - "type": "PropertyValue", - "value": "https://bastianallgeier.com" - }, - { - "name": "Project", - "type": "PropertyValue", - "value": "https://getkirby.com" - }, - { - "name": "Github", - "type": "PropertyValue", - "value": "https://github.com/bastianallgeier" - } - ], - "devices": "https://mastodon.social/users/bastianallgeier/collections/devices", - "discoverable": true, - "endpoints": { - "sharedInbox": "https://mastodon.social/inbox" - }, - "featured": "https://mastodon.social/users/bastianallgeier/collections/featured", - "featuredTags": "https://mastodon.social/users/bastianallgeier/collections/tags", - "followers": "https://mastodon.social/users/bastianallgeier/followers", - "following": "https://mastodon.social/users/bastianallgeier/following", - "icon": { - "mediaType": "image/jpeg", - "type": "Image", - "url": "https://files.mastodon.social/accounts/avatars/000/007/393/original/0180a20079617c71.jpg" - }, - "id": "https://mastodon.social/users/bastianallgeier", - "image": { - "mediaType": "image/jpeg", - "type": "Image", - "url": "https://files.mastodon.social/accounts/headers/000/007/393/original/13d644ab46d50478.jpeg" - }, - "inbox": "https://mastodon.social/users/bastianallgeier/inbox", - "indexable": false, - "manuallyApprovesFollowers": false, - "memorial": false, - "name": "Bastian Allgeier", - "outbox": "https://mastodon.social/users/bastianallgeier/outbox", - "preferredUsername": "bastianallgeier", - "publicKey": { - "id": "https://mastodon.social/users/bastianallgeier#main-key", - "owner": "https://mastodon.social/users/bastianallgeier", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3fz+hpgVztO9z6HUhyzv\nwP++ERBBoIwSLKf1TyIM8bvzGFm2YXaO5uxu1HvumYFTYc3ACr3q4j8VUb7NMxkQ\nlzu4QwPjOFJ43O+fY+HSPORXEDW5fXDGC5DGpox4+i08LxRmx7L6YPRUSUuPN8nI\nWyq1Qsq1zOQrNY/rohMXkBdSXxqC3yIRqvtLt4otCgay/5tMogJWkkS6ZKyFhb9z\nwVVy1fsbV10c9C+SHy4NH26CKaTtpTYLRBMjhTCS8bX8iDSjGIf2aZgYs1ir7gEz\n9wf5CvLiENmVWGwm64t6KSEAkA4NJ1hzgHUZPCjPHZE2SmhO/oHaxokTzqtbbENJ\n1QIDAQAB\n-----END PUBLIC KEY-----\n" - }, - "published": "2016-11-01T00:00:00Z", - "summary": "

Designer & developer. Creator of Kirby CMS

", - "tag": [], - "type": "Person", - "url": "https://mastodon.social/@bastianallgeier" -} diff --git a/test/fixtures/receiver_worker_signature_activity.json b/test/fixtures/receiver_worker_signature_activity.json index 3c3fb3fd2..19dc0087f 100644 --- a/test/fixtures/receiver_worker_signature_activity.json +++ b/test/fixtures/receiver_worker_signature_activity.json @@ -1,62 +1,109 @@ { "@context": [ "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", { + "claim": { + "@id": "toot:claim", + "@type": "@id" + }, + "memorial": "toot:memorial", "atomUri": "ostatus:atomUri", + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", "blurhash": "toot:blurhash", - "conversation": "ostatus:conversation", + "ostatus": "http://ostatus.org#", + "discoverable": "toot:discoverable", "focalPoint": { "@container": "@list", "@id": "toot:focalPoint" }, - "inReplyToAtomUri": "ostatus:inReplyToAtomUri", - "ostatus": "http://ostatus.org#", + "votersCount": "toot:votersCount", + "Hashtag": "as:Hashtag", + "Emoji": "toot:Emoji", + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, "sensitive": "as:sensitive", + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "inReplyToAtomUri": "ostatus:inReplyToAtomUri", + "conversation": "ostatus:conversation", + "Device": "toot:Device", + "schema": "http://schema.org#", "toot": "http://joinmastodon.org/ns#", - "votersCount": "toot:votersCount" + "cipherText": "toot:cipherText", + "suspended": "toot:suspended", + "messageType": "toot:messageType", + "featuredTags": { + "@id": "toot:featuredTags", + "@type": "@id" + }, + "Curve25519Key": "toot:Curve25519Key", + "deviceId": "toot:deviceId", + "Ed25519Signature": "toot:Ed25519Signature", + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "devices": { + "@id": "toot:devices", + "@type": "@id" + }, + "value": "schema:value", + "PropertyValue": "schema:PropertyValue", + "messageFranking": "toot:messageFranking", + "publicKeyBase64": "toot:publicKeyBase64", + "identityKey": { + "@id": "toot:identityKey", + "@type": "@id" + }, + "Ed25519Key": "toot:Ed25519Key", + "indexable": "toot:indexable", + "EncryptedMessage": "toot:EncryptedMessage", + "fingerprintKey": { + "@id": "toot:fingerprintKey", + "@type": "@id" + } } ], - "atomUri": "https://chaos.social/users/distantnative/statuses/109336635639931467", - "attachment": [ - { - "blurhash": "UAK1zS00OXIUxuMxIUM{?b-:-;W:Di?b%2M{", - "height": 960, - "mediaType": "image/jpeg", - "name": null, - "type": "Document", - "url": "https://assets.chaos.social/media_attachments/files/109/336/634/286/114/657/original/2e6122063d8bfb26.jpeg", - "width": 346 - } - ], - "attributedTo": "https://chaos.social/users/distantnative", - "cc": [ - "https://chaos.social/users/distantnative/followers" - ], - "content": "

Favorite piece of anthropology meta discourse.

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

Favorite piece of anthropology meta discourse.

" - }, - "conversation": "tag:chaos.social,2022-11-13:objectId=71843781:objectType=Conversation", - "id": "https://chaos.social/users/distantnative/statuses/109336635639931467", + "actor": "https://phpc.social/users/denniskoch", + "cc": [], + "id": "https://phpc.social/users/denniskoch/statuses/112847382711461301/activity", "inReplyTo": null, "inReplyToAtomUri": null, - "published": "2022-11-13T13:04:20Z", - "replies": { - "first": { - "items": [], - "next": "https://chaos.social/users/distantnative/statuses/109336635639931467/replies?only_other_accounts=true&page=true", - "partOf": "https://chaos.social/users/distantnative/statuses/109336635639931467/replies", - "type": "CollectionPage" + "object": { + "atomUri": "https://phpc.social/users/denniskoch/statuses/112847382711461301", + "attachment": [], + "attributedTo": "https://phpc.social/users/denniskoch", + "cc": [], + "content": "

@bastianallgeier @distantnative @kev Another main argument: Discord is popular. Many people have an account, so you can just join an server quickly. Also you know the app and how to get around.

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

@bastianallgeier @distantnative @kev Another main argument: Discord is popular. Many people have an account, so you can just join an server quickly. Also you know the app and how to get around.

" }, - "id": "https://chaos.social/users/distantnative/statuses/109336635639931467/replies", - "type": "Collection" + "conversation": "tag:mastodon.social,2024-07-25:objectId=760068442:objectType=Conversation", + "id": "https://phpc.social/users/denniskoch/statuses/112847382711461301", + "published": "2024-07-25T13:33:29Z", + "replies": null, + "sensitive": false, + "tag": [], + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "type": "Note", + "url": "https://phpc.social/@denniskoch/112847382711461301" + }, + "published": "2024-07-25T13:33:29Z", + "signature": { + "created": "2024-07-25T13:33:29Z", + "creator": "https://phpc.social/users/denniskoch#main-key", + "signatureValue": "slz9BKJzd2n1S44wdXGOU+bV/wsskdgAaUpwxj8R16mYOL8+DTpE6VnfSKoZGsBBJT8uG5gnVfVEz1YsTUYtymeUgLMh7cvd8VnJnZPS+oixbmBRVky/Myf91TEgQQE7G4vDmTdB4ii54hZrHcOOYYf5FKPNRSkMXboKA6LMqNtekhbI+JTUJYIB02WBBK6PUyo15f6B1RJ6HGWVgud9NE0y1EZXfrkqUt682p8/9D49ORf7AwjXUJibKic2RbPvhEBj70qUGfBm4vvgdWhSUn1IG46xh+U0+NrTSUED82j1ZVOeua/2k/igkGs8cSBkY35quXTkPz6gbqCCH66CuA==", + "type": "RsaSignature2017" }, - "sensitive": false, - "summary": null, - "tag": [], "to": [ "https://www.w3.org/ns/activitystreams#Public" ], - "type": "Note", - "url": "https://chaos.social/@distantnative/109336635639931467" + "type": "Create" } diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index 085108e37..cb434f52e 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -128,23 +128,6 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do test "it can validate the signature" do Tesla.Mock.mock(fn - %{url: "https://mastodon.social/users/bastianallgeier"} -> - %Tesla.Env{ - status: 200, - body: File.read!("test/fixtures/bastianallgeier.json"), - headers: [{"content-type", "application/activity+json"}] - } - - %{url: "https://mastodon.social/users/bastianallgeier/collections/featured"} -> - %Tesla.Env{ - status: 200, - headers: [{"content-type", "application/activity+json"}], - body: - File.read!("test/fixtures/users_mock/masto_featured.json") - |> String.replace("{{domain}}", "mastodon.social") - |> String.replace("{{nickname}}", "bastianallgeier") - } - %{url: "https://phpc.social/users/denniskoch"} -> %Tesla.Env{ status: 200, @@ -161,136 +144,10 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do |> String.replace("{{domain}}", "phpc.social") |> String.replace("{{nickname}}", "denniskoch") } - - %{url: "https://mastodon.social/users/bastianallgeier/statuses/112846516276907281"} -> - %Tesla.Env{ - status: 200, - headers: [{"content-type", "application/activity+json"}], - body: File.read!("test/fixtures/receiver_worker_signature_activity.json") - } end) - params = %{ - "@context" => [ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1", - %{ - "claim" => %{"@id" => "toot:claim", "@type" => "@id"}, - "memorial" => "toot:memorial", - "atomUri" => "ostatus:atomUri", - "manuallyApprovesFollowers" => "as:manuallyApprovesFollowers", - "blurhash" => "toot:blurhash", - "ostatus" => "http://ostatus.org#", - "discoverable" => "toot:discoverable", - "focalPoint" => %{"@container" => "@list", "@id" => "toot:focalPoint"}, - "votersCount" => "toot:votersCount", - "Hashtag" => "as:Hashtag", - "Emoji" => "toot:Emoji", - "alsoKnownAs" => %{"@id" => "as:alsoKnownAs", "@type" => "@id"}, - "sensitive" => "as:sensitive", - "movedTo" => %{"@id" => "as:movedTo", "@type" => "@id"}, - "inReplyToAtomUri" => "ostatus:inReplyToAtomUri", - "conversation" => "ostatus:conversation", - "Device" => "toot:Device", - "schema" => "http://schema.org#", - "toot" => "http://joinmastodon.org/ns#", - "cipherText" => "toot:cipherText", - "suspended" => "toot:suspended", - "messageType" => "toot:messageType", - "featuredTags" => %{"@id" => "toot:featuredTags", "@type" => "@id"}, - "Curve25519Key" => "toot:Curve25519Key", - "deviceId" => "toot:deviceId", - "Ed25519Signature" => "toot:Ed25519Signature", - "featured" => %{"@id" => "toot:featured", "@type" => "@id"}, - "devices" => %{"@id" => "toot:devices", "@type" => "@id"}, - "value" => "schema:value", - "PropertyValue" => "schema:PropertyValue", - "messageFranking" => "toot:messageFranking", - "publicKeyBase64" => "toot:publicKeyBase64", - "identityKey" => %{"@id" => "toot:identityKey", "@type" => "@id"}, - "Ed25519Key" => "toot:Ed25519Key", - "indexable" => "toot:indexable", - "EncryptedMessage" => "toot:EncryptedMessage", - "fingerprintKey" => %{"@id" => "toot:fingerprintKey", "@type" => "@id"} - } - ], - "actor" => "https://phpc.social/users/denniskoch", - "cc" => [ - "https://phpc.social/users/denniskoch/followers", - "https://mastodon.social/users/bastianallgeier", - "https://chaos.social/users/distantnative", - "https://fosstodon.org/users/kev" - ], - "id" => "https://phpc.social/users/denniskoch/statuses/112847382711461301/activity", - "object" => %{ - "atomUri" => "https://phpc.social/users/denniskoch/statuses/112847382711461301", - "attachment" => [], - "attributedTo" => "https://phpc.social/users/denniskoch", - "cc" => [ - "https://phpc.social/users/denniskoch/followers", - "https://mastodon.social/users/bastianallgeier", - "https://chaos.social/users/distantnative", - "https://fosstodon.org/users/kev" - ], - "content" => - "

@bastianallgeier @distantnative @kev Another main argument: Discord is popular. Many people have an account, so you can just join an server quickly. Also you know the app and how to get around.

", - "contentMap" => %{ - "en" => - "

@bastianallgeier @distantnative @kev Another main argument: Discord is popular. Many people have an account, so you can just join an server quickly. Also you know the app and how to get around.

" - }, - "conversation" => - "tag:mastodon.social,2024-07-25:objectId=760068442:objectType=Conversation", - "id" => "https://phpc.social/users/denniskoch/statuses/112847382711461301", - "inReplyTo" => - "https://mastodon.social/users/bastianallgeier/statuses/112846516276907281", - "inReplyToAtomUri" => - "https://mastodon.social/users/bastianallgeier/statuses/112846516276907281", - "published" => "2024-07-25T13:33:29Z", - "replies" => %{ - "first" => %{ - "items" => [], - "next" => - "https://phpc.social/users/denniskoch/statuses/112847382711461301/replies?only_other_accounts=true&page=true", - "partOf" => - "https://phpc.social/users/denniskoch/statuses/112847382711461301/replies", - "type" => "CollectionPage" - }, - "id" => "https://phpc.social/users/denniskoch/statuses/112847382711461301/replies", - "type" => "Collection" - }, - "sensitive" => false, - "tag" => [ - %{ - "href" => "https://mastodon.social/users/bastianallgeier", - "name" => "@bastianallgeier@mastodon.social", - "type" => "Mention" - }, - %{ - "href" => "https://chaos.social/users/distantnative", - "name" => "@distantnative@chaos.social", - "type" => "Mention" - }, - %{ - "href" => "https://fosstodon.org/users/kev", - "name" => "@kev@fosstodon.org", - "type" => "Mention" - } - ], - "to" => ["https://www.w3.org/ns/activitystreams#Public"], - "type" => "Note", - "url" => "https://phpc.social/@denniskoch/112847382711461301" - }, - "published" => "2024-07-25T13:33:29Z", - "signature" => %{ - "created" => "2024-07-25T13:33:29Z", - "creator" => "https://phpc.social/users/denniskoch#main-key", - "signatureValue" => - "slz9BKJzd2n1S44wdXGOU+bV/wsskdgAaUpwxj8R16mYOL8+DTpE6VnfSKoZGsBBJT8uG5gnVfVEz1YsTUYtymeUgLMh7cvd8VnJnZPS+oixbmBRVky/Myf91TEgQQE7G4vDmTdB4ii54hZrHcOOYYf5FKPNRSkMXboKA6LMqNtekhbI+JTUJYIB02WBBK6PUyo15f6B1RJ6HGWVgud9NE0y1EZXfrkqUt682p8/9D49ORf7AwjXUJibKic2RbPvhEBj70qUGfBm4vvgdWhSUn1IG46xh+U0+NrTSUED82j1ZVOeua/2k/igkGs8cSBkY35quXTkPz6gbqCCH66CuA==", - "type" => "RsaSignature2017" - }, - "to" => ["https://www.w3.org/ns/activitystreams#Public"], - "type" => "Create" - } + params = + File.read!("test/fixtures/receiver_worker_signature_activity.json") |> Jason.decode!() req_headers = [ ["accept-encoding", "gzip"], From 3dadb9ed086fb63a3e664a43be3bf30f9ffbfb2d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 16:37:46 -0400 Subject: [PATCH 038/212] Changelog --- changelog.d/oban-recevier-user-error.fix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/oban-recevier-user-error.fix diff --git a/changelog.d/oban-recevier-user-error.fix b/changelog.d/oban-recevier-user-error.fix new file mode 100644 index 000000000..1ed0c5bb1 --- /dev/null +++ b/changelog.d/oban-recevier-user-error.fix @@ -0,0 +1 @@ +ReceiverWorker will cancel processing jobs instead of retrying if the user cannot be fetched due to 403, 404, or 410 errors. From bb2f4a76b3af4ad5f0e2950ef8dc2567c6ad69ff Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 17:01:30 -0400 Subject: [PATCH 039/212] Add test for origin containment failures --- test/pleroma/workers/receiver_worker_test.exs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index cb434f52e..995f765a1 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -177,4 +177,21 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do assert {:ok, %Pleroma.Activity{}} = ReceiverWorker.perform(oban_job) end + + test "cancels due to origin containment" do + params = + insert(:note_activity).data + |> Map.put("id", "https://notorigindomain.com/activity") + + {:ok, oban_job} = + Federator.incoming_ap_doc(%{ + method: "POST", + req_headers: [], + request_path: "/inbox", + params: params, + query_string: "" + }) + + assert {:cancel, :origin_containment_failed} = ReceiverWorker.perform(oban_job) + end end From 6ae629cfe072d236453d256017618fe9a8c44755 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 17:24:59 -0400 Subject: [PATCH 040/212] Cancel ReceiverWorker jobs if the user account has been disabled / deactivated --- lib/pleroma/workers/receiver_worker.ex | 4 ++- test/pleroma/workers/receiver_worker_test.exs | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index 7dce02a5f..80518f6fd 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -33,7 +33,8 @@ defmodule Pleroma.Workers.ReceiverWorker do query_string: query_string } - with {:ok, %User{} = _actor} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]), + with {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]), + {:user_active, true} <- {:user_active, match?(true, actor.is_active)}, {:ok, _public_key} <- Signature.refetch_public_key(conn_data), {:signature, true} <- {:signature, Signature.validate_signature(conn_data)}, {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do @@ -70,6 +71,7 @@ defmodule Pleroma.Workers.ReceiverWorker do {:error, {:side_effects, {:error, :no_object_actor}} = reason} -> {:cancel, reason} {:error, :not_found} = reason -> {:cancel, reason} {:error, :forbidden} = reason -> {:cancel, reason} + {:user_active, false} = reason -> {:cancel, reason} {:error, _} = e -> e e -> {:error, e} end diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index 995f765a1..adf90ec86 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do import Mock import Pleroma.Factory + alias Pleroma.User alias Pleroma.Web.Federator alias Pleroma.Workers.ReceiverWorker @@ -124,6 +125,31 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do assert {:cancel, {:error, :not_found}} = ReceiverWorker.perform(oban_job) end + + test "when user account is disabled" do + user = insert(:user) + + fake_activity = URI.parse(user.ap_id) |> Map.put(:path, "/fake-activity") |> to_string + + params = + insert(:note_activity, user: user).data + |> Map.put("id", fake_activity) + + {:ok, %User{}} = User.set_activation(user, false) + + {:ok, oban_job} = + ReceiverWorker.new(%{ + "op" => "incoming_ap_doc", + "method" => "POST", + "req_headers" => [], + "request_path" => "/inbox", + "params" => params, + "query_string" => "" + }) + |> Oban.insert() + + assert {:cancel, {:user_active, false}} = ReceiverWorker.perform(oban_job) + end end test "it can validate the signature" do From 2e9515578a689428027ca7084d5c9b0d0b4a60ba Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 17:38:13 -0400 Subject: [PATCH 041/212] ReceiverWorker job canceled due to deleted object --- test/pleroma/workers/receiver_worker_test.exs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index adf90ec86..779e83eaa 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -220,4 +220,29 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do assert {:cancel, :origin_containment_failed} = ReceiverWorker.perform(oban_job) end + + test "canceled due to deleted object" do + params = + insert(:announce_activity).data + |> Map.put("object", "http://localhost:4001/deleted") + + Tesla.Mock.mock(fn + %{url: "http://localhost:4001/deleted"} -> + %Tesla.Env{ + status: 404, + body: "" + } + end) + + {:ok, oban_job} = + Federator.incoming_ap_doc(%{ + method: "POST", + req_headers: [], + request_path: "/inbox", + params: params, + query_string: "" + }) + + assert {:cancel, _} = ReceiverWorker.perform(oban_job) + end end From 2346807ac93d5acb9901823cceaffe5c305c1e20 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 17:44:33 -0400 Subject: [PATCH 042/212] Annotate error cases --- lib/pleroma/workers/receiver_worker.ex | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index 80518f6fd..4b1f74a27 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -61,17 +61,21 @@ defmodule Pleroma.Workers.ReceiverWorker do defp process_errors(errors) do case errors do - {:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed} - {:error, :already_present} -> {:cancel, :already_present} - {:error, {:validate_object, _} = reason} -> {:cancel, reason} - {:error, {:validate, {:error, _changeset} = reason}} -> {:cancel, reason} - {:error, {:reject, _} = reason} -> {:cancel, reason} - {:signature, false} -> {:cancel, :invalid_signature} - {:error, "Object has been deleted"} = reason -> {:cancel, reason} - {:error, {:side_effects, {:error, :no_object_actor}} = reason} -> {:cancel, reason} + # User fetch failures {:error, :not_found} = reason -> {:cancel, reason} {:error, :forbidden} = reason -> {:cancel, reason} + # Inactive user {:user_active, false} = reason -> {:cancel, reason} + # Validator will error and return a changeset error + # e.g., duplicate activities or if the object was deleted + {:error, {:validate, {:error, _changeset} = reason}} -> {:cancel, reason} + # MRFs will return a reject + {:error, {:reject, _} = reason} -> {:cancel, reason} + # HTTP Sigs + {:signature, false} -> {:cancel, :invalid_signature} + {:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed} + {:error, {:validate_object, _} = reason} -> {:cancel, reason} + {:error, {:side_effects, {:error, :no_object_actor}} = reason} -> {:cancel, reason} {:error, _} = e -> e e -> {:error, e} end From 380a6a6df31a16a89f5c5cc497ddc1360cea3854 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 17:45:31 -0400 Subject: [PATCH 043/212] :validate_object is not a real error returned from anywhere --- lib/pleroma/web/federator.ex | 5 ----- lib/pleroma/workers/receiver_worker.ex | 1 - 2 files changed, 6 deletions(-) diff --git a/lib/pleroma/web/federator.ex b/lib/pleroma/web/federator.ex index 2df716556..e812b1a46 100644 --- a/lib/pleroma/web/federator.ex +++ b/lib/pleroma/web/federator.ex @@ -121,11 +121,6 @@ defmodule Pleroma.Web.Federator do Logger.debug("Unhandled actor #{actor}, #{inspect(e)}") {:error, e} - {:error, {:validate_object, _}} = e -> - Logger.error("Incoming AP doc validation error: #{inspect(e)}") - Logger.debug(Jason.encode!(params, pretty: true)) - e - e -> # Just drop those for now Logger.debug(fn -> "Unhandled activity\n" <> Jason.encode!(params, pretty: true) end) diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index 4b1f74a27..c7e6bc5ea 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -74,7 +74,6 @@ defmodule Pleroma.Workers.ReceiverWorker do # HTTP Sigs {:signature, false} -> {:cancel, :invalid_signature} {:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed} - {:error, {:validate_object, _} = reason} -> {:cancel, reason} {:error, {:side_effects, {:error, :no_object_actor}} = reason} -> {:cancel, reason} {:error, _} = e -> e e -> {:error, e} From c5ca806aa0023e25755947a3bf0d54242e45f65a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 17:57:34 -0400 Subject: [PATCH 044/212] Add back one of the duplicate checks to fix a test, document where it comes from --- lib/pleroma/workers/receiver_worker.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index c7e6bc5ea..810fda67c 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -69,6 +69,8 @@ defmodule Pleroma.Workers.ReceiverWorker do # Validator will error and return a changeset error # e.g., duplicate activities or if the object was deleted {:error, {:validate, {:error, _changeset} = reason}} -> {:cancel, reason} + # Duplicate detection during Normalization + {:error, :already_present} -> {:cancel, :already_present} # MRFs will return a reject {:error, {:reject, _} = reason} -> {:cancel, reason} # HTTP Sigs From 8a3efa7152488460934c1fadc8ab86efd7d47c04 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 18:02:35 -0400 Subject: [PATCH 045/212] More error annotations --- lib/pleroma/workers/receiver_worker.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index 810fda67c..6787a59ef 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -75,8 +75,11 @@ defmodule Pleroma.Workers.ReceiverWorker do {:error, {:reject, _} = reason} -> {:cancel, reason} # HTTP Sigs {:signature, false} -> {:cancel, :invalid_signature} + # Origin / URL validation failed somewhere possibly due to spoofing {:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed} + # Unclear if this can be reached {:error, {:side_effects, {:error, :no_object_actor}} = reason} -> {:cancel, reason} + # Catchall {:error, _} = e -> e e -> {:error, e} end From e498d252e44ddc1a85288b80dc65beefcd60edf2 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 18:03:33 -0400 Subject: [PATCH 046/212] Changelog update --- changelog.d/oban-recevier-improvements.fix | 1 + changelog.d/oban-recevier-user-error.fix | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 changelog.d/oban-recevier-improvements.fix delete mode 100644 changelog.d/oban-recevier-user-error.fix diff --git a/changelog.d/oban-recevier-improvements.fix b/changelog.d/oban-recevier-improvements.fix new file mode 100644 index 000000000..f91502ed2 --- /dev/null +++ b/changelog.d/oban-recevier-improvements.fix @@ -0,0 +1 @@ +ReceiverWorker will cancel processing jobs instead of retrying if the user cannot be fetched due to 403, 404, or 410 errors or if the account is disabled locally. diff --git a/changelog.d/oban-recevier-user-error.fix b/changelog.d/oban-recevier-user-error.fix deleted file mode 100644 index 1ed0c5bb1..000000000 --- a/changelog.d/oban-recevier-user-error.fix +++ /dev/null @@ -1 +0,0 @@ -ReceiverWorker will cancel processing jobs instead of retrying if the user cannot be fetched due to 403, 404, or 410 errors. From 1821ef4f157980bdf64f7540ee5aa8e26fa3102e Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 18:35:01 -0400 Subject: [PATCH 047/212] Move user active check into Federator.perform/1 --- lib/pleroma/web/federator.ex | 3 ++- lib/pleroma/workers/receiver_worker.ex | 5 ++--- test/pleroma/workers/receiver_worker_test.exs | 14 ++++++-------- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/lib/pleroma/web/federator.ex b/lib/pleroma/web/federator.ex index e812b1a46..58260afa8 100644 --- a/lib/pleroma/web/federator.ex +++ b/lib/pleroma/web/federator.ex @@ -102,7 +102,8 @@ defmodule Pleroma.Web.Federator do # NOTE: we use the actor ID to do the containment, this is fine because an # actor shouldn't be acting on objects outside their own AP server. - with {_, {:ok, _user}} <- {:actor, User.get_or_fetch_by_ap_id(actor)}, + with {_, {:ok, user}} <- {:actor, User.get_or_fetch_by_ap_id(actor)}, + {:user_active, true} <- {:user_active, match?(true, user.is_active)}, nil <- Activity.normalize(params["id"]), {_, :ok} <- {:correct_origin?, Containment.contain_origin_from_id(actor, params)}, diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index 6787a59ef..0373ec15f 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -33,8 +33,7 @@ defmodule Pleroma.Workers.ReceiverWorker do query_string: query_string } - with {:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]), - {:user_active, true} <- {:user_active, match?(true, actor.is_active)}, + with {:ok, %User{}} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]), {:ok, _public_key} <- Signature.refetch_public_key(conn_data), {:signature, true} <- {:signature, Signature.validate_signature(conn_data)}, {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do @@ -65,7 +64,7 @@ defmodule Pleroma.Workers.ReceiverWorker do {:error, :not_found} = reason -> {:cancel, reason} {:error, :forbidden} = reason -> {:cancel, reason} # Inactive user - {:user_active, false} = reason -> {:cancel, reason} + {:error, {:user_active, false} = reason} -> {:cancel, reason} # Validator will error and return a changeset error # e.g., duplicate activities or if the object was deleted {:error, {:validate, {:error, _changeset} = reason}} -> {:cancel, reason} diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index 779e83eaa..4d53c44ed 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -138,15 +138,13 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do {:ok, %User{}} = User.set_activation(user, false) {:ok, oban_job} = - ReceiverWorker.new(%{ - "op" => "incoming_ap_doc", - "method" => "POST", - "req_headers" => [], - "request_path" => "/inbox", - "params" => params, - "query_string" => "" + Federator.incoming_ap_doc(%{ + method: "POST", + req_headers: [], + request_path: "/inbox", + params: params, + query_string: "" }) - |> Oban.insert() assert {:cancel, {:user_active, false}} = ReceiverWorker.perform(oban_job) end From 0bf82a1745a38a3752f5b7df645a7d266b8fd9c8 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 19:50:51 -0400 Subject: [PATCH 048/212] Add an AdapterHelper for Finch so we can support streaming request bodies --- lib/pleroma/http/adapter_helper.ex | 2 ++ lib/pleroma/http/adapter_helper/finch.ex | 33 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 lib/pleroma/http/adapter_helper/finch.ex diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index 4dbcccdcc..be00ba78a 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -52,6 +52,7 @@ defmodule Pleroma.HTTP.AdapterHelper do case adapter() do Tesla.Adapter.Gun -> AdapterHelper.Gun Tesla.Adapter.Hackney -> AdapterHelper.Hackney + {Tesla.Adapter.Finch, _} -> AdapterHelper.Finch _ -> AdapterHelper.Default end end @@ -124,6 +125,7 @@ defmodule Pleroma.HTTP.AdapterHelper do def can_stream? do case Application.get_env(:tesla, :adapter) do Tesla.Adapter.Gun -> true + {Tesla.Adapter.Finch, _} -> true _ -> false end end diff --git a/lib/pleroma/http/adapter_helper/finch.ex b/lib/pleroma/http/adapter_helper/finch.ex new file mode 100644 index 000000000..10a988901 --- /dev/null +++ b/lib/pleroma/http/adapter_helper/finch.ex @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.HTTP.AdapterHelper.Finch do + @behaviour Pleroma.HTTP.AdapterHelper + + alias Pleroma.Config + alias Pleroma.HTTP.AdapterHelper + + @spec options(keyword(), URI.t()) :: keyword() + def options(incoming_opts \\ [], %URI{} = _uri) do + proxy = + [:http, :proxy_url] + |> Config.get() + |> AdapterHelper.format_proxy() + + config_opts = Config.get([:http, :adapter], []) + + config_opts + |> Keyword.merge(incoming_opts) + |> AdapterHelper.maybe_add_proxy(proxy) + |> maybe_stream() + end + + # Tesla Finch adapter uses response: :stream + defp maybe_stream(opts) do + case Keyword.pop(opts, :stream, nil) do + {true, opts} -> Keyword.put(opts, :response, :stream) + {_, opts} -> opts + end + end +end From 8ab4dd20dfdd0cc92c18ade7d84bfb5364785a15 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 19:52:29 -0400 Subject: [PATCH 049/212] Update comments, remove solved TODO --- lib/pleroma/http/adapter_helper.ex | 1 - lib/pleroma/http/adapter_helper/finch.ex | 2 +- lib/pleroma/http/adapter_helper/gun.ex | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/http/adapter_helper.ex b/lib/pleroma/http/adapter_helper.ex index be00ba78a..32c1080f7 100644 --- a/lib/pleroma/http/adapter_helper.ex +++ b/lib/pleroma/http/adapter_helper.ex @@ -120,7 +120,6 @@ defmodule Pleroma.HTTP.AdapterHelper do end end - # TODO add Finch support once we have an AdapterHelper for it @spec can_stream? :: bool() def can_stream? do case Application.get_env(:tesla, :adapter) do diff --git a/lib/pleroma/http/adapter_helper/finch.ex b/lib/pleroma/http/adapter_helper/finch.ex index 10a988901..181caed7e 100644 --- a/lib/pleroma/http/adapter_helper/finch.ex +++ b/lib/pleroma/http/adapter_helper/finch.ex @@ -23,7 +23,7 @@ defmodule Pleroma.HTTP.AdapterHelper.Finch do |> maybe_stream() end - # Tesla Finch adapter uses response: :stream + # Finch uses [response: :stream] defp maybe_stream(opts) do case Keyword.pop(opts, :stream, nil) do {true, opts} -> Keyword.put(opts, :response, :stream) diff --git a/lib/pleroma/http/adapter_helper/gun.ex b/lib/pleroma/http/adapter_helper/gun.ex index f9a8180f2..30ba26765 100644 --- a/lib/pleroma/http/adapter_helper/gun.ex +++ b/lib/pleroma/http/adapter_helper/gun.ex @@ -48,7 +48,7 @@ defmodule Pleroma.HTTP.AdapterHelper.Gun do Keyword.put(opts, :timeout, recv_timeout) end - # Tesla Gun adapter uses body_as: :stream + # Gun uses [body_as: :stream] defp maybe_stream(opts) do case Keyword.pop(opts, :stream, nil) do {true, opts} -> Keyword.put(opts, :body_as, :stream) From d01569822e0dc45349c321ad306f6e19b4e967af Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 28 Aug 2024 19:56:09 -0400 Subject: [PATCH 050/212] Changelog --- changelog.d/rich-media-no-heads.change | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/rich-media-no-heads.change diff --git a/changelog.d/rich-media-no-heads.change b/changelog.d/rich-media-no-heads.change new file mode 100644 index 000000000..0bab323aa --- /dev/null +++ b/changelog.d/rich-media-no-heads.change @@ -0,0 +1 @@ +Rich Media preview fetching will skip making an HTTP HEAD request to check a URL for allowed content type and length if the Tesla adapter is Gun or Finch From c17a78c55a6b288c271923f730dc69aaf27e6556 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 29 Aug 2024 09:37:10 -0400 Subject: [PATCH 051/212] Rich Media: add stream byte counting as an extra protection against malicious URLs --- lib/pleroma/web/rich_media/helpers.ex | 34 +++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/rich_media/helpers.ex b/lib/pleroma/web/rich_media/helpers.ex index a242ca640..d4be97957 100644 --- a/lib/pleroma/web/rich_media/helpers.ex +++ b/lib/pleroma/web/rich_media/helpers.ex @@ -23,7 +23,7 @@ defmodule Pleroma.Web.RichMedia.Helpers do {:get, Pleroma.HTTP.get(url, req_headers(), http_options())}, {_, :ok} <- {:content_type, check_content_type(headers)}, {_, :ok} <- {:content_length, check_content_length(headers)}, - body <- Enum.into(stream_body, <<>>) do + {:read_stream, {:ok, body}} <- {:read_stream, read_stream(stream_body)} do {:ok, body} end end @@ -52,8 +52,12 @@ defmodule Pleroma.Web.RichMedia.Helpers do Logger.debug("Rich media error for #{url}: content-type is #{type}") {:error, :content_type} - {:content_length, {_, length}} -> - Logger.debug("Rich media error for #{url}: content-length is #{length}") + {:content_length, :error} -> + Logger.debug("Rich media error for #{url}: content-length exceeded") + {:error, :body_too_large} + + {:read_stream, :error} -> + Logger.debug("Rich media error for #{url}: content-length exceeded") {:error, :body_too_large} {:get, _} -> @@ -82,7 +86,7 @@ defmodule Pleroma.Web.RichMedia.Helpers do {_, maybe_content_length} -> case Integer.parse(maybe_content_length) do {content_length, ""} when content_length <= max_body -> :ok - {_, ""} -> {:error, maybe_content_length} + {_, ""} -> :error _ -> :ok end @@ -91,6 +95,28 @@ defmodule Pleroma.Web.RichMedia.Helpers do end end + defp read_stream(stream) do + max_body = Keyword.get(http_options(), :max_body) + + try do + result = + Stream.transform(stream, 0, fn chunk, total_bytes -> + new_total = total_bytes + byte_size(chunk) + + if new_total > max_body do + raise("Exceeds max body limit of #{max_body}") + else + {[chunk], new_total} + end + end) + |> Enum.into(<<>>) + + {:ok, result} + rescue + _ -> :error + end + end + defp http_options do [ pool: :rich_media, From ceffb8a8918b83d482e9c1da64fec22b428a61f3 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 23 Aug 2024 13:52:19 -0400 Subject: [PATCH 052/212] Drop incoming Delete activities from unknown actors --- changelog.d/drop-unknown-deletes.change | 1 + lib/pleroma/workers/receiver_worker.ex | 16 +++++++++++++- test/pleroma/workers/receiver_worker_test.exs | 22 +++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 changelog.d/drop-unknown-deletes.change diff --git a/changelog.d/drop-unknown-deletes.change b/changelog.d/drop-unknown-deletes.change new file mode 100644 index 000000000..0be7f5450 --- /dev/null +++ b/changelog.d/drop-unknown-deletes.change @@ -0,0 +1 @@ +Drop incoming Delete activities from unknown actors diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index d4db97b63..ea86a3a12 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -33,7 +33,8 @@ defmodule Pleroma.Workers.ReceiverWorker do query_string: query_string } - with {:ok, %User{} = _actor} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]), + with {_, false} <- {:unknown_delete, unknown_delete?(params)}, + User.get_or_fetch_by_ap_id(conn_data.params["actor"]), {:ok, _public_key} <- Signature.refetch_public_key(conn_data), {:signature, true} <- {:signature, Signature.validate_signature(conn_data)}, {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do @@ -58,6 +59,7 @@ defmodule Pleroma.Workers.ReceiverWorker do defp process_errors(errors) do case errors do + {:unknown_delete, true} -> {:cancel, "Delete from unknown actor"} {:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed} {:error, :already_present} -> {:cancel, :already_present} {:error, {:validate_object, _} = reason} -> {:cancel, reason} @@ -71,4 +73,16 @@ defmodule Pleroma.Workers.ReceiverWorker do e -> {:error, e} end end + + defp unknown_delete?(%{ + "type" => "Delete", + "actor" => actor + }) do + case User.get_cached_by_ap_id(actor) do + %User{} -> false + _ -> true + end + end + + defp unknown_delete?(_), do: false end diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index 33be91085..91fbb1fe8 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -245,4 +245,26 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do assert {:ok, %Pleroma.Activity{}} = ReceiverWorker.perform(oban_job) end + + # When activity is delivered to the inbox and we cannot immediately verify signature + # we capture all the params and process it later in the Oban job. + # This requires we replicate the same scenario by including additional fields in the params + test "Deletes cancelled for an unknown actor" do + params = %{ + "type" => "Delete", + "actor" => "https://unknown.mastodon.instance/users/somebody" + } + + assert {:cancel, "Delete from unknown actor"} = + ReceiverWorker.perform(%Oban.Job{ + args: %{ + "op" => "incoming_ap_doc", + "method" => :post, + "req_headers" => [], + "request_path" => "/inbox", + "query_string" => "", + "params" => params + } + }) + end end From 4bc6f334f49c27e1f466052fee6fa2f3d5d2ee74 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 23 Aug 2024 14:18:04 -0400 Subject: [PATCH 053/212] Revert unintentional change --- lib/pleroma/workers/receiver_worker.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index ea86a3a12..4033fec0f 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -34,7 +34,7 @@ defmodule Pleroma.Workers.ReceiverWorker do } with {_, false} <- {:unknown_delete, unknown_delete?(params)}, - User.get_or_fetch_by_ap_id(conn_data.params["actor"]), + {:ok, %User{} = _actor} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]), {:ok, _public_key} <- Signature.refetch_public_key(conn_data), {:signature, true} <- {:signature, Signature.validate_signature(conn_data)}, {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do From 1c394dd18c5d61dc716a9b9cda981a359de32456 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 23 Aug 2024 14:24:04 -0400 Subject: [PATCH 054/212] Move the check to the inbox --- .../activity_pub/activity_pub_controller.ex | 32 +++++++++++++++---- lib/pleroma/workers/receiver_worker.ex | 15 +-------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index cdd054e1a..a24dcfc9c 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -294,13 +294,19 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end def inbox(%{assigns: %{valid_signature: false}} = conn, params) do - Federator.incoming_ap_doc(%{ - method: conn.method, - req_headers: conn.req_headers, - request_path: conn.request_path, - params: params, - query_string: conn.query_string - }) + case unknown_delete?(params) do + true -> + :ok + + false -> + Federator.incoming_ap_doc(%{ + method: conn.method, + req_headers: conn.req_headers, + request_path: conn.request_path, + params: params, + query_string: conn.query_string + }) + end json(conn, "ok") end @@ -558,4 +564,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> json(UserView.render("featured.json", %{user: user})) end end + + defp unknown_delete?(%{ + "type" => "Delete", + "actor" => actor + }) do + case User.get_cached_by_ap_id(actor) do + %User{} -> false + _ -> true + end + end + + defp unknown_delete?(_), do: false end diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index 4033fec0f..ce92ef9dd 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -33,8 +33,7 @@ defmodule Pleroma.Workers.ReceiverWorker do query_string: query_string } - with {_, false} <- {:unknown_delete, unknown_delete?(params)}, - {:ok, %User{} = _actor} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]), + with {:ok, %User{} = _actor} <- User.get_or_fetch_by_ap_id(conn_data.params["actor"]), {:ok, _public_key} <- Signature.refetch_public_key(conn_data), {:signature, true} <- {:signature, Signature.validate_signature(conn_data)}, {:ok, res} <- Federator.perform(:incoming_ap_doc, params) do @@ -73,16 +72,4 @@ defmodule Pleroma.Workers.ReceiverWorker do e -> {:error, e} end end - - defp unknown_delete?(%{ - "type" => "Delete", - "actor" => actor - }) do - case User.get_cached_by_ap_id(actor) do - %User{} -> false - _ -> true - end - end - - defp unknown_delete?(_), do: false end From 27fcc421719062d5de9bf4dc90f3349595eb278d Mon Sep 17 00:00:00 2001 From: feld Date: Sat, 24 Aug 2024 16:53:22 +0000 Subject: [PATCH 055/212] Use Pleroma.Object.Containment.get_actor/1 to reliably find the actor of an incoming activity or object --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index a24dcfc9c..77ec26f14 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -567,9 +567,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do defp unknown_delete?(%{ "type" => "Delete", - "actor" => actor - }) do - case User.get_cached_by_ap_id(actor) do + } = data) do + case data |> Pleroma.Object.Containment.get_actor() |> User.get_cached_by_ap_id() do %User{} -> false _ -> true end From 7bcc21ad6f1fdf9dbc16990e9891f9de7a21011d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 24 Aug 2024 13:01:28 -0400 Subject: [PATCH 056/212] Switch test to the inbox --- .../activity_pub_controller_test.exs | 21 ++++++++++++++++++ test/pleroma/workers/receiver_worker_test.exs | 22 ------------------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index af1a32fed..b9067533c 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -684,6 +684,27 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do |> json_response(400) end + # When activity is delivered to the inbox and we cannot immediately verify signature + # we capture all the params and process it later in the Oban job. + # Once we begin processing it through Oban we risk fetching the actor to validate the + # activity which just leads to inserting a new user to process a Delete not relevant to us. + test "Deletes from an unknown actor are discarded", %{conn: conn} do + params = + %{ + "type" => "Delete", + "actor" => "https://unknown.mastodon.instance/users/somebody" + } + |> Jason.encode!() + + conn + |> assign(:valid_signature, false) + |> put_req_header("content-type", "application/activity+json") + |> post("/inbox", params) + |> json_response(200) + + assert all_enqueued() == [] + end + test "accepts Add/Remove activities", %{conn: conn} do object_id = "c61d6733-e256-4fe1-ab13-1e369789423f" diff --git a/test/pleroma/workers/receiver_worker_test.exs b/test/pleroma/workers/receiver_worker_test.exs index 91fbb1fe8..33be91085 100644 --- a/test/pleroma/workers/receiver_worker_test.exs +++ b/test/pleroma/workers/receiver_worker_test.exs @@ -245,26 +245,4 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do assert {:ok, %Pleroma.Activity{}} = ReceiverWorker.perform(oban_job) end - - # When activity is delivered to the inbox and we cannot immediately verify signature - # we capture all the params and process it later in the Oban job. - # This requires we replicate the same scenario by including additional fields in the params - test "Deletes cancelled for an unknown actor" do - params = %{ - "type" => "Delete", - "actor" => "https://unknown.mastodon.instance/users/somebody" - } - - assert {:cancel, "Delete from unknown actor"} = - ReceiverWorker.perform(%Oban.Job{ - args: %{ - "op" => "incoming_ap_doc", - "method" => :post, - "req_headers" => [], - "request_path" => "/inbox", - "query_string" => "", - "params" => params - } - }) - end end From 06deacd58e6aa676847530f24c6799162ed06777 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 24 Aug 2024 20:03:50 -0400 Subject: [PATCH 057/212] Formatting --- lib/pleroma/web/activity_pub/activity_pub_controller.ex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 77ec26f14..4c83d1927 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -565,9 +565,11 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end end - defp unknown_delete?(%{ - "type" => "Delete", - } = data) do + defp unknown_delete?( + %{ + "type" => "Delete" + } = data + ) do case data |> Pleroma.Object.Containment.get_actor() |> User.get_cached_by_ap_id() do %User{} -> false _ -> true From 16a9b34876f3a9289c02253e802bffdac4901ac0 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 29 Aug 2024 14:16:59 -0400 Subject: [PATCH 058/212] Convert to an Plug called InboxGuard --- lib/pleroma/constants.ex | 12 ++++ .../activity_pub/activity_pub_controller.ex | 33 ++-------- lib/pleroma/web/plugs/inbox_guard_plug.ex | 66 +++++++++++++++++++ lib/pleroma/web/router.ex | 6 +- .../activity_pub_controller_test.exs | 2 +- 5 files changed, 91 insertions(+), 28 deletions(-) create mode 100644 lib/pleroma/web/plugs/inbox_guard_plug.ex diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 3a5e35301..f969bfdbb 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -85,6 +85,18 @@ defmodule Pleroma.Constants do ] ) + const(allowed_activity_types_from_strangers, + do: [ + "Block", + "Create", + "Flag", + "Follow", + "Like", + "Move", + "React" + ] + ) + # basic regex, just there to weed out potential mistakes # https://datatracker.ietf.org/doc/html/rfc2045#section-5.1 const(mime_regex, diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 4c83d1927..cdd054e1a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -294,19 +294,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do end def inbox(%{assigns: %{valid_signature: false}} = conn, params) do - case unknown_delete?(params) do - true -> - :ok - - false -> - Federator.incoming_ap_doc(%{ - method: conn.method, - req_headers: conn.req_headers, - request_path: conn.request_path, - params: params, - query_string: conn.query_string - }) - end + Federator.incoming_ap_doc(%{ + method: conn.method, + req_headers: conn.req_headers, + request_path: conn.request_path, + params: params, + query_string: conn.query_string + }) json(conn, "ok") end @@ -564,17 +558,4 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> json(UserView.render("featured.json", %{user: user})) end end - - defp unknown_delete?( - %{ - "type" => "Delete" - } = data - ) do - case data |> Pleroma.Object.Containment.get_actor() |> User.get_cached_by_ap_id() do - %User{} -> false - _ -> true - end - end - - defp unknown_delete?(_), do: false end diff --git a/lib/pleroma/web/plugs/inbox_guard_plug.ex b/lib/pleroma/web/plugs/inbox_guard_plug.ex new file mode 100644 index 000000000..643b586d4 --- /dev/null +++ b/lib/pleroma/web/plugs/inbox_guard_plug.ex @@ -0,0 +1,66 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Plugs.InboxGuardPlug do + import Plug.Conn + import Pleroma.Constants, only: [allowed_activity_types_from_strangers: 0] + + alias Pleroma.Config + alias Pleroma.User + + def init(options) do + options + end + + def call(%{assigns: %{valid_signature: true}} = conn, _opts) do + conn + end + + def call(conn, _opts) do + with {_, true} <- {:federating, Config.get!([:instance, :federating])}, + true <- known_actor?(conn) do + conn + else + {:federating, false} -> + conn + |> json(403, "Not federating") + + _ -> + conn + |> filter_from_strangers() + end + end + + # If signature failed but we know this actor we should + # accept it as we may only need to refetch their public key + # during processing + defp known_actor?(%{body_params: data}) do + case Pleroma.Object.Containment.get_actor(data) |> User.get_cached_by_ap_id() do + %User{} -> true + _ -> false + end + end + + # Only permit a subset of activity types from strangers + # or else it will add actors you've never interacted with + # to the database + defp filter_from_strangers(%{body_params: %{"type" => type}} = conn) do + with true <- type in allowed_activity_types_from_strangers() do + conn + else + _ -> + conn + |> json(400, "Invalid activity type for an unknown actor") + end + end + + defp json(conn, status, resp) do + json_resp = Jason.encode!(resp) + + conn + |> put_resp_content_type("application/json") + |> resp(status, json_resp) + |> halt() + end +end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 6492e3861..9b9ee421c 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -217,6 +217,10 @@ defmodule Pleroma.Web.Router do plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug) end + pipeline :inbox_guard do + plug(Pleroma.Web.Plugs.InboxGuardPlug) + end + pipeline :static_fe do plug(Pleroma.Web.Plugs.StaticFEPlug) end @@ -920,7 +924,7 @@ defmodule Pleroma.Web.Router do end scope "/", Pleroma.Web.ActivityPub do - pipe_through(:activitypub) + pipe_through([:activitypub, :inbox_guard]) post("/inbox", ActivityPubController, :inbox) post("/users/:nickname/inbox", ActivityPubController, :inbox) end diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index b9067533c..1b959f324 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -700,7 +700,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do |> assign(:valid_signature, false) |> put_req_header("content-type", "application/activity+json") |> post("/inbox", params) - |> json_response(200) + |> json_response(400) assert all_enqueued() == [] end From e2cdae2c88eb22588209923d83c2a9c52d16c48c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 29 Aug 2024 14:23:07 -0400 Subject: [PATCH 059/212] Change relay inbox response when not federating to a 403 for consistency --- 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 cdd054e1a..a08eda5f4 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -311,7 +311,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do post_inbox_relayed_create(conn, params) else conn - |> put_status(:bad_request) + |> put_status(403) |> json("Not federating") end end From 990b2058df11cc32f260d0ffcf7dd0f342d435b4 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 29 Aug 2024 14:30:23 -0400 Subject: [PATCH 060/212] Remove unnecessary error match in ReceiverWorker --- lib/pleroma/workers/receiver_worker.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index ce92ef9dd..d4db97b63 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -58,7 +58,6 @@ defmodule Pleroma.Workers.ReceiverWorker do defp process_errors(errors) do case errors do - {:unknown_delete, true} -> {:cancel, "Delete from unknown actor"} {:error, :origin_containment_failed} -> {:cancel, :origin_containment_failed} {:error, :already_present} -> {:cancel, :already_present} {:error, {:validate_object, _} = reason} -> {:cancel, reason} From 2b39956acbc3ccd87a43cd4ddbd5976adcac5936 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 29 Aug 2024 14:40:31 -0400 Subject: [PATCH 061/212] Fix test title to be more specific as it has a broader but incorrect meaning --- test/pleroma/web/activity_pub/activity_pub_controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index 1b959f324..762fca0a1 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -657,7 +657,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do end test "without valid signature, " <> - "it only accepts Create activities and requires enabled federation", + "it accepts Create activities and requires enabled federation", %{conn: conn} do data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() non_create_data = File.read!("test/fixtures/mastodon-announce.json") |> Jason.decode!() From 012132303f79c0d693a8fba7236433443261b757 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 29 Aug 2024 14:40:45 -0400 Subject: [PATCH 062/212] Test more types we do not want to receive from strangers --- .../activity_pub_controller_test.exs | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index 762fca0a1..453dbaf0c 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -688,21 +688,25 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do # we capture all the params and process it later in the Oban job. # Once we begin processing it through Oban we risk fetching the actor to validate the # activity which just leads to inserting a new user to process a Delete not relevant to us. - test "Deletes from an unknown actor are discarded", %{conn: conn} do - params = - %{ - "type" => "Delete", - "actor" => "https://unknown.mastodon.instance/users/somebody" - } - |> Jason.encode!() + test "Activities of certain types from an unknown actor are discarded", %{conn: conn} do + example_bad_types = ["Announce", "Delete", "Undo"] - conn - |> assign(:valid_signature, false) - |> put_req_header("content-type", "application/activity+json") - |> post("/inbox", params) - |> json_response(400) + Enum.each(example_bad_types, fn bad_type -> + params = + %{ + "type" => bad_type, + "actor" => "https://unknown.mastodon.instance/users/somebody" + } + |> Jason.encode!() - assert all_enqueued() == [] + conn + |> assign(:valid_signature, false) + |> put_req_header("content-type", "application/activity+json") + |> post("/inbox", params) + |> json_response(400) + + assert all_enqueued() == [] + end) end test "accepts Add/Remove activities", %{conn: conn} do From 094da5d6343507eb1540f0a6357accc67573e02e Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 29 Aug 2024 14:44:52 -0400 Subject: [PATCH 063/212] Update changelog --- changelog.d/drop-unknown-deletes.change | 1 - changelog.d/drop-unwanted.change | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 changelog.d/drop-unknown-deletes.change create mode 100644 changelog.d/drop-unwanted.change diff --git a/changelog.d/drop-unknown-deletes.change b/changelog.d/drop-unknown-deletes.change deleted file mode 100644 index 0be7f5450..000000000 --- a/changelog.d/drop-unknown-deletes.change +++ /dev/null @@ -1 +0,0 @@ -Drop incoming Delete activities from unknown actors diff --git a/changelog.d/drop-unwanted.change b/changelog.d/drop-unwanted.change new file mode 100644 index 000000000..59447d68f --- /dev/null +++ b/changelog.d/drop-unwanted.change @@ -0,0 +1 @@ +Restrict incoming activities from unknown actors to a subset that does not imply a previous relationship From 5205e846eb5cfbd0adfa5031ad75e96fccbc86d8 Mon Sep 17 00:00:00 2001 From: feld Date: Fri, 30 Aug 2024 13:14:05 +0000 Subject: [PATCH 064/212] Update allowed activity types from strangers Move is emitted from the old account EmojiReact is ~ Like Announced TBD --- lib/pleroma/constants.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index f969bfdbb..bbd183683 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -92,8 +92,8 @@ defmodule Pleroma.Constants do "Flag", "Follow", "Like", - "Move", - "React" + "EmojiReact", + "Announce" ] ) From e38f5f1a817d6da30e9a128ec74a2a7c78faf174 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 30 Aug 2024 09:35:04 -0400 Subject: [PATCH 065/212] Add recognized activity types to a constant and use it in the test --- lib/pleroma/constants.ex | 18 ++++++++++++++++++ .../activity_pub_controller_test.exs | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index bbd183683..5268ebe7a 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -85,6 +85,24 @@ defmodule Pleroma.Constants do ] ) + const(activity_types, + do: [ + "Create", + "Update", + "Delete", + "Follow", + "Accept", + "Reject", + "Add", + "Remove", + "Like", + "Announce", + "Undo", + "Flag", + "EmojiReact" + ] + ) + const(allowed_activity_types_from_strangers, do: [ "Block", diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index 453dbaf0c..c32f6c1a3 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -689,7 +689,9 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do # Once we begin processing it through Oban we risk fetching the actor to validate the # activity which just leads to inserting a new user to process a Delete not relevant to us. test "Activities of certain types from an unknown actor are discarded", %{conn: conn} do - example_bad_types = ["Announce", "Delete", "Undo"] + example_bad_types = + Pleroma.Constants.activity_types() -- + Pleroma.Constants.allowed_activity_types_from_strangers() Enum.each(example_bad_types, fn bad_type -> params = From 11ee94ae17094a2bc33505a31671b8c705f768a4 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 30 Aug 2024 09:46:10 -0400 Subject: [PATCH 066/212] InboxGuardPlug: Add early rejection of unknown activity types --- lib/pleroma/web/plugs/inbox_guard_plug.ex | 31 ++++++++++++++++--- .../activity_pub_controller_test.exs | 21 +++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/web/plugs/inbox_guard_plug.ex b/lib/pleroma/web/plugs/inbox_guard_plug.ex index 643b586d4..0064cce76 100644 --- a/lib/pleroma/web/plugs/inbox_guard_plug.ex +++ b/lib/pleroma/web/plugs/inbox_guard_plug.ex @@ -4,7 +4,7 @@ defmodule Pleroma.Web.Plugs.InboxGuardPlug do import Plug.Conn - import Pleroma.Constants, only: [allowed_activity_types_from_strangers: 0] + import Pleroma.Constants, only: [activity_types: 0, allowed_activity_types_from_strangers: 0] alias Pleroma.Config alias Pleroma.User @@ -14,24 +14,46 @@ defmodule Pleroma.Web.Plugs.InboxGuardPlug do end def call(%{assigns: %{valid_signature: true}} = conn, _opts) do - conn + with {_, true} <- {:federating, Config.get!([:instance, :federating])} do + conn + |> filter_activity_types() + else + {:federating, false} -> + conn + |> json(403, "Not federating") + |> halt() + end end def call(conn, _opts) do with {_, true} <- {:federating, Config.get!([:instance, :federating])}, - true <- known_actor?(conn) do + conn = filter_activity_types(conn), + {:known, true} <- {:known, known_actor?(conn)} do conn else {:federating, false} -> conn |> json(403, "Not federating") + |> halt() - _ -> + {:known, false} -> conn |> filter_from_strangers() end end + # Early rejection of unrecognized types + defp filter_activity_types(%{body_params: %{"type" => type}} = conn) do + with true <- type in activity_types() do + conn + else + _ -> + conn + |> json(400, "Invalid activity type") + |> halt() + end + end + # If signature failed but we know this actor we should # accept it as we may only need to refetch their public key # during processing @@ -52,6 +74,7 @@ defmodule Pleroma.Web.Plugs.InboxGuardPlug do _ -> conn |> json(400, "Invalid activity type for an unknown actor") + |> halt() end end diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index c32f6c1a3..3bd589f49 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -711,6 +711,27 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do end) end + test "Unknown activity types are discarded", %{conn: conn} do + unknown_types = ["Poke", "Read", "Dazzle"] + + Enum.each(unknown_types, fn bad_type -> + params = + %{ + "type" => bad_type, + "actor" => "https://unknown.mastodon.instance/users/somebody" + } + |> Jason.encode!() + + conn + |> assign(:valid_signature, true) + |> put_req_header("content-type", "application/activity+json") + |> post("/inbox", params) + |> json_response(400) + + assert all_enqueued() == [] + end) + end + test "accepts Add/Remove activities", %{conn: conn} do object_id = "c61d6733-e256-4fe1-ab13-1e369789423f" From bb235f913fb88f925abc791285808afe63d14bca Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 30 Aug 2024 10:03:51 -0400 Subject: [PATCH 067/212] Update changelog --- changelog.d/drop-unwanted.change | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/drop-unwanted.change b/changelog.d/drop-unwanted.change index 59447d68f..459d4bfe6 100644 --- a/changelog.d/drop-unwanted.change +++ b/changelog.d/drop-unwanted.change @@ -1 +1 @@ -Restrict incoming activities from unknown actors to a subset that does not imply a previous relationship +Restrict incoming activities from unknown actors to a subset that does not imply a previous relationship and early rejection of unrecognized activity types. From 4ae17c62944eab89acfa96a0912819093c872436 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 30 Aug 2024 15:25:21 -0400 Subject: [PATCH 068/212] NodeInfo: Accept application/activity+json requests --- changelog.d/well-known.change | 1 + lib/pleroma/web/router.ex | 2 +- test/pleroma/web/node_info_test.exs | 13 +++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 changelog.d/well-known.change diff --git a/changelog.d/well-known.change b/changelog.d/well-known.change new file mode 100644 index 000000000..e928124fb --- /dev/null +++ b/changelog.d/well-known.change @@ -0,0 +1 @@ +Accept application/activity+json for requests to .well-known/nodeinfo diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 6492e3861..9e4b403e0 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -189,7 +189,7 @@ defmodule Pleroma.Web.Router do end pipeline :well_known do - plug(:accepts, ["json", "jrd", "jrd+json", "xml", "xrd+xml"]) + plug(:accepts, ["activity+json", "json", "jrd", "jrd+json", "xml", "xrd+xml"]) end pipeline :config do diff --git a/test/pleroma/web/node_info_test.exs b/test/pleroma/web/node_info_test.exs index f474220be..afe4ebb36 100644 --- a/test/pleroma/web/node_info_test.exs +++ b/test/pleroma/web/node_info_test.exs @@ -24,6 +24,19 @@ defmodule Pleroma.Web.NodeInfoTest do |> get(href) |> json_response(200) end) + + accept_types = [ + "application/activity+json", + "application/json", + "application/jrd+json" + ] + + for type <- accept_types do + conn + |> put_req_header("accept", type) + |> get("/.well-known/nodeinfo") + |> json_response(200) + end end test "nodeinfo shows staff accounts", %{conn: conn} do From 5a1144208d1007af2a2d2279c582adf9d2fa7246 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 1 Sep 2024 12:26:59 -0400 Subject: [PATCH 069/212] Prevent OAuth App flow from creating duplicate entries --- changelog.d/oauth-app.fix | 1 + .../controllers/app_controller.ex | 4 +- .../controllers/app_controller_test.exs | 47 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 changelog.d/oauth-app.fix diff --git a/changelog.d/oauth-app.fix b/changelog.d/oauth-app.fix new file mode 100644 index 000000000..eb917462f --- /dev/null +++ b/changelog.d/oauth-app.fix @@ -0,0 +1 @@ +Prevent OAuth App flow from creating duplicate entries diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex index 844673ae0..e5e8ea8f5 100644 --- a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -33,11 +33,9 @@ defmodule Pleroma.Web.MastodonAPI.AppController do app_attrs = params |> Map.take([:client_name, :redirect_uris, :website]) - |> Map.put(:scopes, scopes) |> Maps.put_if_present(:user_id, user_id) - with cs <- App.register_changeset(%App{}, app_attrs), - {:ok, app} <- Repo.insert(cs) do + with {:ok, app} <- App.get_or_make(app_attrs, scopes) do render(conn, "show.json", app: app) end end diff --git a/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs index bc9d4048c..1e2e68791 100644 --- a/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs @@ -89,4 +89,51 @@ defmodule Pleroma.Web.MastodonAPI.AppControllerTest do assert expected == json_response_and_validate_schema(conn, 200) assert app.user_id == user.id end + + test "creates an oauth app without a user", %{conn: conn} do + app_attrs = build(:oauth_app) + + conn = + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/apps", %{ + client_name: app_attrs.client_name, + redirect_uris: app_attrs.redirect_uris + }) + + [app] = Repo.all(App) + + expected = %{ + "name" => app.client_name, + "website" => app.website, + "client_id" => app.client_id, + "client_secret" => app.client_secret, + "id" => app.id |> to_string(), + "redirect_uri" => app.redirect_uris, + "vapid_key" => Push.vapid_config() |> Keyword.get(:public_key) + } + + assert expected == json_response_and_validate_schema(conn, 200) + end + + test "does not duplicate apps with the same client name", %{conn: conn} do + client_name = "BleromaSE" + redirect_uris = "https://bleroma.app/oauth-callback" + + for _i <- 1..3 do + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/apps", %{ + client_name: client_name, + redirect_uris: redirect_uris + }) + |> json_response_and_validate_schema(200) + end + + apps = Repo.all(App) + + assert length(apps) == 1 + assert List.first(apps).client_name == client_name + assert List.first(apps).redirect_uris == redirect_uris + end end From e3a7c1d906698d2f36661b60de4bdab5a475b871 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 1 Sep 2024 12:37:59 -0400 Subject: [PATCH 070/212] Test that app scopes can be updated --- .../controllers/app_controller_test.exs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs index 1e2e68791..26c431673 100644 --- a/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs @@ -136,4 +136,37 @@ defmodule Pleroma.Web.MastodonAPI.AppControllerTest do assert List.first(apps).client_name == client_name assert List.first(apps).redirect_uris == redirect_uris end + + test "app scopes can be updated", %{conn: conn} do + client_name = "BleromaSE" + redirect_uris = "https://bleroma.app/oauth-callback" + website = "https://bleromase.com" + scopes = "read write" + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/apps", %{ + client_name: client_name, + redirect_uris: redirect_uris, + website: website, + scopes: scopes + }) + |> json_response_and_validate_schema(200) + + assert List.first(Repo.all(App)).scopes == String.split(scopes, " ") + + updated_scopes = "read write push" + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/apps", %{ + client_name: client_name, + redirect_uris: redirect_uris, + website: website, + scopes: updated_scopes + }) + |> json_response_and_validate_schema(200) + + assert List.first(Repo.all(App)).scopes == String.split(updated_scopes, " ") + end end From 751d63d4bb05caececf52a3a3b134182e57a059d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 1 Sep 2024 13:34:30 -0400 Subject: [PATCH 071/212] Support OAuth App updating the website URL --- .../controllers/app_controller.ex | 3 +- lib/pleroma/web/o_auth/app.ex | 24 +++++---------- .../controllers/app_controller_test.exs | 30 +++++++++++++++++++ test/pleroma/web/o_auth/app_test.exs | 15 ++++++---- 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex index e5e8ea8f5..4677ac40a 100644 --- a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -33,9 +33,10 @@ defmodule Pleroma.Web.MastodonAPI.AppController do app_attrs = params |> Map.take([:client_name, :redirect_uris, :website]) + |> Map.put(:scopes, scopes) |> Maps.put_if_present(:user_id, user_id) - with {:ok, app} <- App.get_or_make(app_attrs, scopes) do + with {:ok, app} <- App.get_or_make(app_attrs) do render(conn, "show.json", app: app) end end diff --git a/lib/pleroma/web/o_auth/app.ex b/lib/pleroma/web/o_auth/app.ex index d1bf6dd18..889850c73 100644 --- a/lib/pleroma/web/o_auth/app.ex +++ b/lib/pleroma/web/o_auth/app.ex @@ -67,35 +67,27 @@ defmodule Pleroma.Web.OAuth.App do with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do app |> changeset(params) + |> validate_required([:scopes]) |> Repo.update() end end @doc """ - Gets app by attrs or create new with attrs. - And updates the scopes if need. + Gets app by attrs or create new with attrs. + Updates the attrs if needed. """ - @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) + @spec get_or_make(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} + def get_or_make(attrs) do + with %__MODULE__{} = app <- Repo.get_by(__MODULE__, client_name: attrs.client_name) do + __MODULE__.update(app.id, Map.take(attrs, [:scopes, :website])) else _e -> %__MODULE__{} - |> register_changeset(Map.put(attrs, :scopes, scopes)) + |> register_changeset(attrs) |> Repo.insert() end end - defp update_scopes(%__MODULE__{} = app, []), do: {:ok, app} - defp update_scopes(%__MODULE__{scopes: scopes} = app, scopes), do: {:ok, app} - - defp update_scopes(%__MODULE__{} = app, scopes) do - app - |> change(%{scopes: scopes}) - |> Repo.update() - end - @spec search(map()) :: {:ok, [t()], non_neg_integer()} def search(params) do query = from(a in __MODULE__) diff --git a/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs index 26c431673..df28f2010 100644 --- a/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs @@ -169,4 +169,34 @@ defmodule Pleroma.Web.MastodonAPI.AppControllerTest do assert List.first(Repo.all(App)).scopes == String.split(updated_scopes, " ") end + + test "app website URL can be updated", %{conn: conn} do + client_name = "BleromaSE" + redirect_uris = "https://bleroma.app/oauth-callback" + website = "https://bleromase.com" + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/apps", %{ + client_name: client_name, + redirect_uris: redirect_uris, + website: website + }) + |> json_response_and_validate_schema(200) + + assert List.first(Repo.all(App)).website == website + + updated_website = "https://bleromase2ultimateedition.com" + + conn + |> put_req_header("content-type", "application/json") + |> post("/api/v1/apps", %{ + client_name: client_name, + redirect_uris: redirect_uris, + website: updated_website + }) + |> json_response_and_validate_schema(200) + + assert List.first(Repo.all(App)).website == updated_website + end end diff --git a/test/pleroma/web/o_auth/app_test.exs b/test/pleroma/web/o_auth/app_test.exs index 96a67de6b..423b660ea 100644 --- a/test/pleroma/web/o_auth/app_test.exs +++ b/test/pleroma/web/o_auth/app_test.exs @@ -12,20 +12,23 @@ defmodule Pleroma.Web.OAuth.AppTest do test "gets exist app" do attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]})) - {:ok, %App{} = exist_app} = App.get_or_make(attrs, []) + {:ok, %App{} = exist_app} = App.get_or_make(attrs) assert exist_app == app end test "make app" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} - {:ok, %App{} = app} = App.get_or_make(attrs, ["write"]) + attrs = %{client_name: "Mastodon-Local", redirect_uris: ".", scopes: ["write"]} + {:ok, %App{} = app} = App.get_or_make(attrs) assert app.scopes == ["write"] end test "gets exist app and updates scopes" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} - app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]})) - {:ok, %App{} = exist_app} = App.get_or_make(attrs, ["read", "write", "follow", "push"]) + attrs = %{client_name: "Mastodon-Local", redirect_uris: ".", scopes: ["read", "write"]} + app = insert(:oauth_app, attrs) + + {:ok, %App{} = exist_app} = + App.get_or_make(%{attrs | scopes: ["read", "write", "follow", "push"]}) + assert exist_app.id == app.id assert exist_app.scopes == ["read", "write", "follow", "push"] end From 37397a43be7e73efeb1004f59fc9e69bc75da927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Mon, 2 Sep 2024 12:39:29 +0200 Subject: [PATCH 072/212] scrubbers/default: Allow "mention hashtag" classes used by Mastodon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- changelog.d/scrubbers-allow-mention-hashtag.add | 1 + priv/scrubbers/default.ex | 3 ++- priv/scrubbers/twitter_text.ex | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 changelog.d/scrubbers-allow-mention-hashtag.add diff --git a/changelog.d/scrubbers-allow-mention-hashtag.add b/changelog.d/scrubbers-allow-mention-hashtag.add new file mode 100644 index 000000000..c12ab1ffb --- /dev/null +++ b/changelog.d/scrubbers-allow-mention-hashtag.add @@ -0,0 +1 @@ +scrubbers/default: Allow "mention hashtag" classes used by Mastodon \ No newline at end of file diff --git a/priv/scrubbers/default.ex b/priv/scrubbers/default.ex index a75a6465d..dad9dc1a1 100644 --- a/priv/scrubbers/default.ex +++ b/priv/scrubbers/default.ex @@ -22,7 +22,8 @@ defmodule Pleroma.HTML.Scrubber.Default do "u-url", "mention", "u-url mention", - "mention u-url" + "mention u-url", + "mention hashtag" ]) Meta.allow_tag_with_this_attribute_values(:a, "rel", [ diff --git a/priv/scrubbers/twitter_text.ex b/priv/scrubbers/twitter_text.ex index 6e23b3efb..4df840735 100644 --- a/priv/scrubbers/twitter_text.ex +++ b/priv/scrubbers/twitter_text.ex @@ -23,7 +23,8 @@ defmodule Pleroma.HTML.Scrubber.TwitterText do "u-url", "mention", "u-url mention", - "mention u-url" + "mention u-url", + "mention hashtag" ]) Meta.allow_tag_with_this_attribute_values(:a, "rel", [ From 6d5ae4d2e91f8ea75115c0ffcdd1d94a24c9dc31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 3 Sep 2024 15:17:45 +0200 Subject: [PATCH 073/212] Include list id in StatusView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- changelog.d/list-id-visibility.add | 1 + .../API/differences_in_mastoapi_responses.md | 1 + lib/pleroma/web/api_spec/schemas/status.ex | 6 ++++++ lib/pleroma/web/mastodon_api/views/status_view.ex | 13 ++++++++++++- .../web/mastodon_api/views/status_view_test.exs | 8 +++++++- 5 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 changelog.d/list-id-visibility.add diff --git a/changelog.d/list-id-visibility.add b/changelog.d/list-id-visibility.add new file mode 100644 index 000000000..2fea2d771 --- /dev/null +++ b/changelog.d/list-id-visibility.add @@ -0,0 +1 @@ +Include list id in StatusView \ No newline at end of file diff --git a/docs/development/API/differences_in_mastoapi_responses.md b/docs/development/API/differences_in_mastoapi_responses.md index 41464e802..3bf8637f8 100644 --- a/docs/development/API/differences_in_mastoapi_responses.md +++ b/docs/development/API/differences_in_mastoapi_responses.md @@ -42,6 +42,7 @@ Has these additional fields under the `pleroma` object: - `quotes_count`: the count of status quotes. - `non_anonymous`: true if the source post specifies the poll results are not anonymous. Currently only implemented by Smithereen. - `bookmark_folder`: the ID of the folder bookmark is stored within (if any). +- `list_id`: the ID of the list the post is addressed to (if any, only returned to author). The `GET /api/v1/statuses/:id/source` endpoint additionally has the following attributes: diff --git a/lib/pleroma/web/api_spec/schemas/status.ex b/lib/pleroma/web/api_spec/schemas/status.ex index 6e537b5da..25548d75b 100644 --- a/lib/pleroma/web/api_spec/schemas/status.ex +++ b/lib/pleroma/web/api_spec/schemas/status.ex @@ -249,6 +249,12 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Status do nullable: true, description: "A datetime (ISO 8601) that states when the post was pinned or `null` if the post is not pinned" + }, + list_id: %Schema{ + type: :integer, + nullable: true, + description: + "The ID of the list the post is addressed to (if any, only returned to author)" } } }, diff --git a/lib/pleroma/web/mastodon_api/views/status_view.ex b/lib/pleroma/web/mastodon_api/views/status_view.ex index 1b78477d0..3bf870c24 100644 --- a/lib/pleroma/web/mastodon_api/views/status_view.ex +++ b/lib/pleroma/web/mastodon_api/views/status_view.ex @@ -465,7 +465,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do parent_visible: visible_for_user?(reply_to, opts[:for]), pinned_at: pinned_at, quotes_count: object.data["quotesCount"] || 0, - bookmark_folder: bookmark_folder + bookmark_folder: bookmark_folder, + list_id: get_list_id(object, client_posted_this_activity) } } end @@ -835,4 +836,14 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do nil end end + + defp get_list_id(object, client_posted_this_activity) do + with true <- client_posted_this_activity, + %{data: %{"listMessage" => list_ap_id}} when is_binary(list_ap_id) <- object, + %{id: list_id} <- Pleroma.List.get_by_ap_id(list_ap_id) do + list_id + else + _ -> nil + end + end end diff --git a/test/pleroma/web/mastodon_api/views/status_view_test.exs b/test/pleroma/web/mastodon_api/views/status_view_test.exs index afe0ccb28..bc6dec32a 100644 --- a/test/pleroma/web/mastodon_api/views/status_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/status_view_test.exs @@ -342,7 +342,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do parent_visible: false, pinned_at: nil, quotes_count: 0, - bookmark_folder: nil + bookmark_folder: nil, + list_id: nil } } @@ -912,6 +913,11 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do status = StatusView.render("show.json", activity: activity) assert status.visibility == "list" + assert status.pleroma.list_id == nil + + status = StatusView.render("show.json", activity: activity, for: user) + + assert status.pleroma.list_id == list.id end test "has a field for parent visibility" do From 92d5f0ac141e679af048ebcbec9cb91df68fd929 Mon Sep 17 00:00:00 2001 From: feld Date: Wed, 4 Sep 2024 02:22:25 +0000 Subject: [PATCH 074/212] Revert "Merge branch 'oauth-app-spam' into 'develop'" This reverts merge request !4244 --- changelog.d/oauth-app.fix | 1 - .../controllers/app_controller.ex | 3 +- lib/pleroma/web/o_auth/app.ex | 24 ++-- .../controllers/app_controller_test.exs | 110 ------------------ test/pleroma/web/o_auth/app_test.exs | 15 +-- 5 files changed, 24 insertions(+), 129 deletions(-) delete mode 100644 changelog.d/oauth-app.fix diff --git a/changelog.d/oauth-app.fix b/changelog.d/oauth-app.fix deleted file mode 100644 index eb917462f..000000000 --- a/changelog.d/oauth-app.fix +++ /dev/null @@ -1 +0,0 @@ -Prevent OAuth App flow from creating duplicate entries diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex index 4677ac40a..844673ae0 100644 --- a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -36,7 +36,8 @@ defmodule Pleroma.Web.MastodonAPI.AppController do |> Map.put(:scopes, scopes) |> Maps.put_if_present(:user_id, user_id) - with {:ok, app} <- App.get_or_make(app_attrs) do + with cs <- App.register_changeset(%App{}, app_attrs), + {:ok, app} <- Repo.insert(cs) do render(conn, "show.json", app: app) end end diff --git a/lib/pleroma/web/o_auth/app.ex b/lib/pleroma/web/o_auth/app.ex index 889850c73..d1bf6dd18 100644 --- a/lib/pleroma/web/o_auth/app.ex +++ b/lib/pleroma/web/o_auth/app.ex @@ -67,27 +67,35 @@ defmodule Pleroma.Web.OAuth.App do with %__MODULE__{} = app <- Repo.get(__MODULE__, id) do app |> changeset(params) - |> validate_required([:scopes]) |> Repo.update() end end @doc """ - Gets app by attrs or create new with attrs. - Updates the attrs if needed. + Gets app by attrs or create new with attrs. + And updates the scopes if need. """ - @spec get_or_make(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()} - def get_or_make(attrs) do - with %__MODULE__{} = app <- Repo.get_by(__MODULE__, client_name: attrs.client_name) do - __MODULE__.update(app.id, Map.take(attrs, [:scopes, :website])) + @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) else _e -> %__MODULE__{} - |> register_changeset(attrs) + |> register_changeset(Map.put(attrs, :scopes, scopes)) |> Repo.insert() end end + defp update_scopes(%__MODULE__{} = app, []), do: {:ok, app} + defp update_scopes(%__MODULE__{scopes: scopes} = app, scopes), do: {:ok, app} + + defp update_scopes(%__MODULE__{} = app, scopes) do + app + |> change(%{scopes: scopes}) + |> Repo.update() + end + @spec search(map()) :: {:ok, [t()], non_neg_integer()} def search(params) do query = from(a in __MODULE__) diff --git a/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs index df28f2010..bc9d4048c 100644 --- a/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/app_controller_test.exs @@ -89,114 +89,4 @@ defmodule Pleroma.Web.MastodonAPI.AppControllerTest do assert expected == json_response_and_validate_schema(conn, 200) assert app.user_id == user.id end - - test "creates an oauth app without a user", %{conn: conn} do - app_attrs = build(:oauth_app) - - conn = - conn - |> put_req_header("content-type", "application/json") - |> post("/api/v1/apps", %{ - client_name: app_attrs.client_name, - redirect_uris: app_attrs.redirect_uris - }) - - [app] = Repo.all(App) - - expected = %{ - "name" => app.client_name, - "website" => app.website, - "client_id" => app.client_id, - "client_secret" => app.client_secret, - "id" => app.id |> to_string(), - "redirect_uri" => app.redirect_uris, - "vapid_key" => Push.vapid_config() |> Keyword.get(:public_key) - } - - assert expected == json_response_and_validate_schema(conn, 200) - end - - test "does not duplicate apps with the same client name", %{conn: conn} do - client_name = "BleromaSE" - redirect_uris = "https://bleroma.app/oauth-callback" - - for _i <- 1..3 do - conn - |> put_req_header("content-type", "application/json") - |> post("/api/v1/apps", %{ - client_name: client_name, - redirect_uris: redirect_uris - }) - |> json_response_and_validate_schema(200) - end - - apps = Repo.all(App) - - assert length(apps) == 1 - assert List.first(apps).client_name == client_name - assert List.first(apps).redirect_uris == redirect_uris - end - - test "app scopes can be updated", %{conn: conn} do - client_name = "BleromaSE" - redirect_uris = "https://bleroma.app/oauth-callback" - website = "https://bleromase.com" - scopes = "read write" - - conn - |> put_req_header("content-type", "application/json") - |> post("/api/v1/apps", %{ - client_name: client_name, - redirect_uris: redirect_uris, - website: website, - scopes: scopes - }) - |> json_response_and_validate_schema(200) - - assert List.first(Repo.all(App)).scopes == String.split(scopes, " ") - - updated_scopes = "read write push" - - conn - |> put_req_header("content-type", "application/json") - |> post("/api/v1/apps", %{ - client_name: client_name, - redirect_uris: redirect_uris, - website: website, - scopes: updated_scopes - }) - |> json_response_and_validate_schema(200) - - assert List.first(Repo.all(App)).scopes == String.split(updated_scopes, " ") - end - - test "app website URL can be updated", %{conn: conn} do - client_name = "BleromaSE" - redirect_uris = "https://bleroma.app/oauth-callback" - website = "https://bleromase.com" - - conn - |> put_req_header("content-type", "application/json") - |> post("/api/v1/apps", %{ - client_name: client_name, - redirect_uris: redirect_uris, - website: website - }) - |> json_response_and_validate_schema(200) - - assert List.first(Repo.all(App)).website == website - - updated_website = "https://bleromase2ultimateedition.com" - - conn - |> put_req_header("content-type", "application/json") - |> post("/api/v1/apps", %{ - client_name: client_name, - redirect_uris: redirect_uris, - website: updated_website - }) - |> json_response_and_validate_schema(200) - - assert List.first(Repo.all(App)).website == updated_website - end end diff --git a/test/pleroma/web/o_auth/app_test.exs b/test/pleroma/web/o_auth/app_test.exs index 423b660ea..96a67de6b 100644 --- a/test/pleroma/web/o_auth/app_test.exs +++ b/test/pleroma/web/o_auth/app_test.exs @@ -12,23 +12,20 @@ defmodule Pleroma.Web.OAuth.AppTest do test "gets exist app" do attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]})) - {:ok, %App{} = exist_app} = App.get_or_make(attrs) + {:ok, %App{} = exist_app} = App.get_or_make(attrs, []) assert exist_app == app end test "make app" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: ".", scopes: ["write"]} - {:ok, %App{} = app} = App.get_or_make(attrs) + attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} + {:ok, %App{} = app} = App.get_or_make(attrs, ["write"]) assert app.scopes == ["write"] end test "gets exist app and updates scopes" do - attrs = %{client_name: "Mastodon-Local", redirect_uris: ".", scopes: ["read", "write"]} - app = insert(:oauth_app, attrs) - - {:ok, %App{} = exist_app} = - App.get_or_make(%{attrs | scopes: ["read", "write", "follow", "push"]}) - + attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} + app = insert(:oauth_app, Map.merge(attrs, %{scopes: ["read", "write"]})) + {:ok, %App{} = exist_app} = App.get_or_make(attrs, ["read", "write", "follow", "push"]) assert exist_app.id == app.id assert exist_app.scopes == ["read", "write", "follow", "push"] end From 427da7a99a30ebc7a7deb54e7704b5d8dffea199 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 4 Sep 2024 09:19:07 -0400 Subject: [PATCH 075/212] Rate Limit the OAuth App spam --- changelog.d/oauth-app-spam.fix | 1 + config/config.exs | 1 + lib/pleroma/web/mastodon_api/controllers/app_controller.ex | 2 ++ 3 files changed, 4 insertions(+) create mode 100644 changelog.d/oauth-app-spam.fix diff --git a/changelog.d/oauth-app-spam.fix b/changelog.d/oauth-app-spam.fix new file mode 100644 index 000000000..0e95c01d7 --- /dev/null +++ b/changelog.d/oauth-app-spam.fix @@ -0,0 +1 @@ +Add a rate limiter to the OAuth App creation endpoint diff --git a/config/config.exs b/config/config.exs index ad6b1cb94..a4fedff45 100644 --- a/config/config.exs +++ b/config/config.exs @@ -711,6 +711,7 @@ config :pleroma, :rate_limit, timeline: {500, 3}, search: [{1000, 10}, {1000, 30}], app_account_creation: {1_800_000, 25}, + oauth_app_creation: {900_000, 5}, relations_actions: {10_000, 10}, relation_id_action: {60_000, 2}, statuses_actions: {10_000, 15}, diff --git a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex index 844673ae0..6cfeb712e 100644 --- a/lib/pleroma/web/mastodon_api/controllers/app_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/app_controller.ex @@ -19,6 +19,8 @@ defmodule Pleroma.Web.MastodonAPI.AppController do action_fallback(Pleroma.Web.MastodonAPI.FallbackController) + plug(Pleroma.Web.Plugs.RateLimiter, [name: :oauth_app_creation] when action == :create) + plug(:skip_auth when action in [:create, :verify_credentials]) plug(Pleroma.Web.ApiSpec.CastAndValidate) From 7bd0750787859cb30382d90162d70380441abc05 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 4 Sep 2024 10:40:37 -0400 Subject: [PATCH 076/212] Ensure apps are assigned to users --- changelog.d/oauth-app-spam.fix | 2 +- lib/pleroma/web/o_auth/app.ex | 10 +++++++++ lib/pleroma/web/o_auth/o_auth_controller.ex | 2 ++ .../20240904142434_assign_app_user.exs | 21 +++++++++++++++++++ .../web/o_auth/o_auth_controller_test.exs | 8 +++++++ 5 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 priv/repo/migrations/20240904142434_assign_app_user.exs diff --git a/changelog.d/oauth-app-spam.fix b/changelog.d/oauth-app-spam.fix index 0e95c01d7..cdc2e816d 100644 --- a/changelog.d/oauth-app-spam.fix +++ b/changelog.d/oauth-app-spam.fix @@ -1 +1 @@ -Add a rate limiter to the OAuth App creation endpoint +Add a rate limiter to the OAuth App creation endpoint and ensure registered apps are assigned to users. diff --git a/lib/pleroma/web/o_auth/app.ex b/lib/pleroma/web/o_auth/app.ex index d1bf6dd18..f0f54bb46 100644 --- a/lib/pleroma/web/o_auth/app.ex +++ b/lib/pleroma/web/o_auth/app.ex @@ -8,6 +8,7 @@ defmodule Pleroma.Web.OAuth.App do import Ecto.Query alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.OAuth.Token @type t :: %__MODULE__{} @@ -155,4 +156,13 @@ defmodule Pleroma.Web.OAuth.App do Map.put(acc, key, error) end) end + + @spec maybe_update_owner(Token.t()) :: :ok + def maybe_update_owner(%Token{app_id: app_id, user_id: user_id}) when not is_nil(user_id) do + __MODULE__.update(app_id, %{user_id: user_id}) + + :ok + end + + def maybe_update_owner(_), do: :ok end diff --git a/lib/pleroma/web/o_auth/o_auth_controller.ex b/lib/pleroma/web/o_auth/o_auth_controller.ex index 47b03215f..0b3de5481 100644 --- a/lib/pleroma/web/o_auth/o_auth_controller.ex +++ b/lib/pleroma/web/o_auth/o_auth_controller.ex @@ -318,6 +318,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params) def after_token_exchange(%Plug.Conn{} = conn, %{token: token} = view_params) do + App.maybe_update_owner(token) + conn |> AuthHelper.put_session_token(token.token) |> json(OAuthView.render("token.json", view_params)) diff --git a/priv/repo/migrations/20240904142434_assign_app_user.exs b/priv/repo/migrations/20240904142434_assign_app_user.exs new file mode 100644 index 000000000..11bec529b --- /dev/null +++ b/priv/repo/migrations/20240904142434_assign_app_user.exs @@ -0,0 +1,21 @@ +defmodule Pleroma.Repo.Migrations.AssignAppUser do + use Ecto.Migration + + alias Pleroma.Repo + alias Pleroma.Web.OAuth.App + alias Pleroma.Web.OAuth.Token + + def up do + Repo.all(Token) + |> Enum.group_by(fn x -> Map.get(x, :app_id) end) + |> Enum.each(fn {_app_id, tokens} -> + token = + Enum.filter(tokens, fn x -> not is_nil(x.user_id) end) + |> List.first() + + App.maybe_update_owner(token) + end) + end + + def down, do: :ok +end diff --git a/test/pleroma/web/o_auth/o_auth_controller_test.exs b/test/pleroma/web/o_auth/o_auth_controller_test.exs index 83a08d9fc..260442771 100644 --- a/test/pleroma/web/o_auth/o_auth_controller_test.exs +++ b/test/pleroma/web/o_auth/o_auth_controller_test.exs @@ -12,6 +12,7 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do alias Pleroma.MFA.TOTP alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.Authorization alias Pleroma.Web.OAuth.OAuthController alias Pleroma.Web.OAuth.Token @@ -770,6 +771,9 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do {:ok, auth} = Authorization.create_authorization(app, user, ["write"]) + # Verify app has no associated user yet + assert %Pleroma.Web.OAuth.App{user_id: nil} = Repo.get_by(App, %{id: app.id}) + conn = build_conn() |> post("/oauth/token", %{ @@ -786,6 +790,10 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do assert token assert token.scopes == auth.scopes assert user.ap_id == ap_id + + # Verify app has an associated user now + user_id = user.id + assert %Pleroma.Web.OAuth.App{user_id: ^user_id} = Repo.get_by(App, %{id: app.id}) end test "issues a token for `password` grant_type with valid credentials, with full permissions by default" do From a1951f3af7e1d5c4d53819962c3e68df5ba4475b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 4 Sep 2024 10:59:58 -0400 Subject: [PATCH 077/212] Add Cron worker to clean up orphaned apps hourly --- config/config.exs | 3 ++- lib/pleroma/web/o_auth/app.ex | 6 ++++++ .../workers/cron/app_cleanup_worker.ex | 21 +++++++++++++++++++ test/pleroma/web/o_auth/app_test.exs | 12 +++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 lib/pleroma/workers/cron/app_cleanup_worker.ex diff --git a/config/config.exs b/config/config.exs index a4fedff45..2bc28b256 100644 --- a/config/config.exs +++ b/config/config.exs @@ -597,7 +597,8 @@ config :pleroma, Oban, plugins: [{Oban.Plugins.Pruner, max_age: 900}], crontab: [ {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, - {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker} + {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker}, + {"0 0 * * *", Pleroma.Workers.Cron.AppCleanupWorker} ] config :pleroma, Pleroma.Formatter, diff --git a/lib/pleroma/web/o_auth/app.ex b/lib/pleroma/web/o_auth/app.ex index f0f54bb46..6ae419d09 100644 --- a/lib/pleroma/web/o_auth/app.ex +++ b/lib/pleroma/web/o_auth/app.ex @@ -165,4 +165,10 @@ defmodule Pleroma.Web.OAuth.App do end def maybe_update_owner(_), do: :ok + + @spec remove_orphans() :: :ok + def remove_orphans() do + from(a in __MODULE__, where: is_nil(a.user_id)) + |> Repo.delete_all() + end end diff --git a/lib/pleroma/workers/cron/app_cleanup_worker.ex b/lib/pleroma/workers/cron/app_cleanup_worker.ex new file mode 100644 index 000000000..ee71cd7b6 --- /dev/null +++ b/lib/pleroma/workers/cron/app_cleanup_worker.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Workers.Cron.AppCleanupWorker do + @moduledoc """ + Cleans up registered apps that were never associated with a user. + """ + + use Oban.Worker, queue: "background" + + alias Pleroma.Web.OAuth.App + + @impl true + def perform(_job) do + App.remove_orphans() + end + + @impl true + def timeout(_job), do: :timer.seconds(30) +end diff --git a/test/pleroma/web/o_auth/app_test.exs b/test/pleroma/web/o_auth/app_test.exs index 96a67de6b..725ea3eb8 100644 --- a/test/pleroma/web/o_auth/app_test.exs +++ b/test/pleroma/web/o_auth/app_test.exs @@ -53,4 +53,16 @@ defmodule Pleroma.Web.OAuth.AppTest do assert Enum.sort(App.get_user_apps(user)) == Enum.sort(apps) end + + test "removes orphaned apps" do + attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} + {:ok, %App{} = app} = App.get_or_make(attrs, ["write"]) + assert app.scopes == ["write"] + + assert app == Pleroma.Repo.get_by(App, %{id: app.id}) + + App.remove_orphans() + + assert nil == Pleroma.Repo.get_by(App, %{id: app.id}) + end end From 53744bf146f157ee1ecfc9ba4de9e5d65fa80784 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 4 Sep 2024 11:43:43 -0400 Subject: [PATCH 078/212] Limit the number of orphaned to delete at 100 every 10 mins due to the cascading queries that have to check oauth_authorizations and oauth_tokens tables. This should keep ahead of most app registration spam and not overwhelm lower powered servers. --- config/config.exs | 2 +- lib/pleroma/web/o_auth/app.ex | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/config/config.exs b/config/config.exs index 2bc28b256..80a3b8d57 100644 --- a/config/config.exs +++ b/config/config.exs @@ -598,7 +598,7 @@ config :pleroma, Oban, crontab: [ {"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker}, {"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker}, - {"0 0 * * *", Pleroma.Workers.Cron.AppCleanupWorker} + {"*/10 * * * *", Pleroma.Workers.Cron.AppCleanupWorker} ] config :pleroma, Pleroma.Formatter, diff --git a/lib/pleroma/web/o_auth/app.ex b/lib/pleroma/web/o_auth/app.ex index 6ae419d09..032011433 100644 --- a/lib/pleroma/web/o_auth/app.ex +++ b/lib/pleroma/web/o_auth/app.ex @@ -166,9 +166,14 @@ defmodule Pleroma.Web.OAuth.App do def maybe_update_owner(_), do: :ok - @spec remove_orphans() :: :ok - def remove_orphans() do - from(a in __MODULE__, where: is_nil(a.user_id)) - |> Repo.delete_all() + @spec remove_orphans(pos_integer()) :: :ok + def remove_orphans(limit \\ 100) do + Repo.transaction(fn -> + from(a in __MODULE__, where: is_nil(a.user_id), limit: ^limit) + |> Repo.all() + |> Enum.each(&Repo.delete(&1)) + end) + + :ok end end From fb376ce0056cf977d3673c99bca4e77c564b4c47 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 5 Sep 2024 15:27:43 -0400 Subject: [PATCH 079/212] Test Account View does not indicate following if a FollowingRelationship is missing --- .../mastodon_api/views/account_view_test.exs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) 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 dca64853d..5d2f55c6d 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -456,6 +456,44 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do test_relationship_rendering(user, other_user, expected) end + test "relationship does not indicate following if a FollowingRelationship is missing" do + user = insert(:user) + other_user = insert(:user, local: false) + + # Create a follow relationship with the real Follow Activity and Accept it + assert {:ok, _, _, _} = CommonAPI.follow(other_user, user) + assert {:ok, _} = CommonAPI.accept_follow_request(user, other_user) + + assert %{data: %{"state" => "accept"}} = + Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(user, other_user) + + # Fetch the relationship and forcibly delete it to simulate a Follow Accept that did not complete processing + %{following_relationships: [relationship]} = + Pleroma.UserRelationship.view_relationships_option(user, [other_user]) + + assert {:ok, _} = Pleroma.Repo.delete(relationship) + + assert %{following_relationships: [], user_relationships: []} == + Pleroma.UserRelationship.view_relationships_option(user, [other_user]) + + expected = + Map.merge( + @blank_response, + %{ + following: false, + followed_by: false, + muting: false, + muting_notifications: false, + subscribing: false, + notifying: false, + showing_reblogs: true, + 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 user = insert(:user) other_user = insert(:user) From 4d76692db36d6779fddacee3a7690739064eae0c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 5 Sep 2024 11:43:48 -0400 Subject: [PATCH 080/212] Fix Following status bug --- changelog.d/following-state.fix | 1 + .../web/mastodon_api/views/account_view.ex | 19 +++++++++---------- 2 files changed, 10 insertions(+), 10 deletions(-) create mode 100644 changelog.d/following-state.fix diff --git a/changelog.d/following-state.fix b/changelog.d/following-state.fix new file mode 100644 index 000000000..314ea6210 --- /dev/null +++ b/changelog.d/following-state.fix @@ -0,0 +1 @@ +Resolved edge case where the API can report you are following a user but the relationship is not fully established. diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 6976ca6e5..298c73986 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -92,14 +92,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do User.get_follow_state(reading_user, target) end - followed_by = - if following_relationships do - case FollowingRelationship.find(following_relationships, target, reading_user) do - %{state: :follow_accept} -> true - _ -> false - end - else - User.following?(target, reading_user) + followed_by = FollowingRelationship.following?(target, reading_user) + following = FollowingRelationship.following?(reading_user, target) + + requested = + cond do + following -> false + true -> match?(:follow_pending, follow_state) end subscribing = @@ -114,7 +113,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do # NOTE: adjust UserRelationship.view_relationships_option/2 on new relation-related flags %{ id: to_string(target.id), - following: follow_state == :follow_accept, + following: following, followed_by: followed_by, blocking: UserRelationship.exists?( @@ -150,7 +149,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do ), subscribing: subscribing, notifying: subscribing, - requested: follow_state == :follow_pending, + requested: requested, domain_blocking: User.blocks_domain?(reading_user, target), showing_reblogs: not UserRelationship.exists?( From 1797f5958a92f78dc79c5bf313755b16319c5d2d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 5 Sep 2024 20:55:28 +0000 Subject: [PATCH 081/212] App orphans should only be removed if they are older than 15 mins --- lib/pleroma/web/o_auth/app.ex | 7 ++++++- test/pleroma/web/o_auth/app_test.exs | 13 +++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/o_auth/app.ex b/lib/pleroma/web/o_auth/app.ex index 032011433..7661c2566 100644 --- a/lib/pleroma/web/o_auth/app.ex +++ b/lib/pleroma/web/o_auth/app.ex @@ -168,8 +168,13 @@ defmodule Pleroma.Web.OAuth.App do @spec remove_orphans(pos_integer()) :: :ok def remove_orphans(limit \\ 100) do + fifteen_mins_ago = DateTime.add(DateTime.utc_now(), -900, :second) + Repo.transaction(fn -> - from(a in __MODULE__, where: is_nil(a.user_id), limit: ^limit) + from(a in __MODULE__, + where: is_nil(a.user_id) and a.inserted_at < ^fifteen_mins_ago, + limit: ^limit + ) |> Repo.all() |> Enum.each(&Repo.delete(&1)) end) diff --git a/test/pleroma/web/o_auth/app_test.exs b/test/pleroma/web/o_auth/app_test.exs index 725ea3eb8..44219cf90 100644 --- a/test/pleroma/web/o_auth/app_test.exs +++ b/test/pleroma/web/o_auth/app_test.exs @@ -56,13 +56,18 @@ defmodule Pleroma.Web.OAuth.AppTest do test "removes orphaned apps" do attrs = %{client_name: "Mastodon-Local", redirect_uris: "."} - {:ok, %App{} = app} = App.get_or_make(attrs, ["write"]) - assert app.scopes == ["write"] + {:ok, %App{} = old_app} = App.get_or_make(attrs, ["write"]) - assert app == Pleroma.Repo.get_by(App, %{id: app.id}) + attrs = %{client_name: "PleromaFE", redirect_uris: "."} + {:ok, %App{} = app} = App.get_or_make(attrs, ["write"]) + + # backdate the old app so it's within the threshold for being cleaned up + {:ok, _} = + "UPDATE apps SET inserted_at = now() - interval '1 hour' WHERE id = #{old_app.id}" + |> Pleroma.Repo.query() App.remove_orphans() - assert nil == Pleroma.Repo.get_by(App, %{id: app.id}) + assert [app] == Pleroma.Repo.all(App) end end From e51cd31a576715d8e7d991e4fce850edcf4c8a1e Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 5 Sep 2024 17:06:53 -0400 Subject: [PATCH 082/212] Bump credo to prevent it from crashing --- mix.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.lock b/mix.lock index 07c1122aa..865e09a4c 100644 --- a/mix.lock +++ b/mix.lock @@ -22,7 +22,7 @@ "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, - "credo": {:hex, :credo, "1.7.3", "05bb11eaf2f2b8db370ecaa6a6bda2ec49b2acd5e0418bc106b73b07128c0436", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "35ea675a094c934c22fb1dca3696f3c31f2728ae6ef5a53b5d648c11180a4535"}, + "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, From 5f573b4095ade584a590b756c83f13f89336cd04 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 5 Sep 2024 17:11:02 -0400 Subject: [PATCH 083/212] Credo: comment line length --- test/pleroma/web/mastodon_api/views/account_view_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 5d2f55c6d..f88b90955 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -467,7 +467,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do assert %{data: %{"state" => "accept"}} = Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(user, other_user) - # Fetch the relationship and forcibly delete it to simulate a Follow Accept that did not complete processing + # Fetch the relationship and forcibly delete it to simulate + # a Follow Accept that did not complete processing %{following_relationships: [relationship]} = Pleroma.UserRelationship.view_relationships_option(user, [other_user]) From a887188890a6b8c9e97c6cafe1776bb151e63843 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 6 Sep 2024 09:42:53 -0400 Subject: [PATCH 084/212] Oban: more unique job constraints --- changelog.d/oban-uniques.change | 1 + lib/pleroma/workers/receiver_worker.ex | 2 +- lib/pleroma/workers/remote_fetcher_worker.ex | 2 +- lib/pleroma/workers/rich_media_worker.ex | 2 +- lib/pleroma/workers/user_refresh_worker.ex | 2 +- lib/pleroma/workers/web_pusher_worker.ex | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 changelog.d/oban-uniques.change diff --git a/changelog.d/oban-uniques.change b/changelog.d/oban-uniques.change new file mode 100644 index 000000000..d9deb4696 --- /dev/null +++ b/changelog.d/oban-uniques.change @@ -0,0 +1 @@ +Adjust more Oban workers to enforce unique job constraints. diff --git a/lib/pleroma/workers/receiver_worker.ex b/lib/pleroma/workers/receiver_worker.ex index 0373ec15f..11b672bef 100644 --- a/lib/pleroma/workers/receiver_worker.ex +++ b/lib/pleroma/workers/receiver_worker.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Workers.ReceiverWorker do alias Pleroma.User alias Pleroma.Web.Federator - use Oban.Worker, queue: :federator_incoming, max_attempts: 5 + use Oban.Worker, queue: :federator_incoming, max_attempts: 5, unique: [period: :infinity] @impl true diff --git a/lib/pleroma/workers/remote_fetcher_worker.ex b/lib/pleroma/workers/remote_fetcher_worker.ex index 9d3f1ec53..aa09362f5 100644 --- a/lib/pleroma/workers/remote_fetcher_worker.ex +++ b/lib/pleroma/workers/remote_fetcher_worker.ex @@ -5,7 +5,7 @@ defmodule Pleroma.Workers.RemoteFetcherWorker do alias Pleroma.Object.Fetcher - use Oban.Worker, queue: :background + use Oban.Worker, queue: :background, unique: [period: :infinity] @impl true def perform(%Job{args: %{"op" => "fetch_remote", "id" => id} = args}) do diff --git a/lib/pleroma/workers/rich_media_worker.ex b/lib/pleroma/workers/rich_media_worker.ex index d5ba7b63e..e351ecd6e 100644 --- a/lib/pleroma/workers/rich_media_worker.ex +++ b/lib/pleroma/workers/rich_media_worker.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Workers.RichMediaWorker do alias Pleroma.Web.RichMedia.Backfill alias Pleroma.Web.RichMedia.Card - use Oban.Worker, queue: :background, max_attempts: 3, unique: [period: 300] + use Oban.Worker, queue: :background, max_attempts: 3, unique: [period: :infinity] @impl true def perform(%Job{args: %{"op" => "expire", "url" => url} = _args}) do diff --git a/lib/pleroma/workers/user_refresh_worker.ex b/lib/pleroma/workers/user_refresh_worker.ex index 222a4a8f7..ee276774b 100644 --- a/lib/pleroma/workers/user_refresh_worker.ex +++ b/lib/pleroma/workers/user_refresh_worker.ex @@ -3,7 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Workers.UserRefreshWorker do - use Oban.Worker, queue: :background, max_attempts: 1, unique: [period: 300] + use Oban.Worker, queue: :background, max_attempts: 1, unique: [period: :infinity] alias Pleroma.User diff --git a/lib/pleroma/workers/web_pusher_worker.ex b/lib/pleroma/workers/web_pusher_worker.ex index f4232d02a..879b26cc3 100644 --- a/lib/pleroma/workers/web_pusher_worker.ex +++ b/lib/pleroma/workers/web_pusher_worker.ex @@ -7,7 +7,7 @@ defmodule Pleroma.Workers.WebPusherWorker do alias Pleroma.Repo alias Pleroma.Web.Push.Impl - use Oban.Worker, queue: :web_push + use Oban.Worker, queue: :web_push, unique: [period: :infinity] @impl true def perform(%Job{args: %{"op" => "web_push", "notification_id" => notification_id}}) do From fc3ea94a1c17e84033fa593c0ea987fbfa545447 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 6 Sep 2024 09:58:03 -0400 Subject: [PATCH 085/212] Dialyzer: the pattern can never match the type --- lib/pleroma/object/fetcher.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 9d9a201ca..ff7aa539f 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -58,8 +58,11 @@ defmodule Pleroma.Object.Fetcher do end end + @typep fetcher_errors :: + :error | :reject | :allowed_depth | :fetch | :containment | :transmogrifier + # Note: will create a Create activity, which we need internally at the moment. - @spec fetch_object_from_id(String.t(), list()) :: {:ok, Object.t()} | {:error | :reject, any()} + @spec fetch_object_from_id(String.t(), list()) :: {:ok, Object.t()} | {fetcher_errors(), any()} def fetch_object_from_id(id, options \\ []) do with {_, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)}, {_, true} <- {:allowed_depth, Federator.allowed_thread_distance?(options[:depth])}, From bc16f09d7b9c1a15c8c9be6d99092b9a8d867d18 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 6 Sep 2024 11:12:13 -0400 Subject: [PATCH 086/212] Dialyzer: the pattern can never match the type The original error was for the chat controller: lib/pleroma/web/pleroma_api/controllers/chat_controller.ex:104:pattern_match The pattern can never match the type {:error, :content_too_long | :forbidden | :no_content | :not_found} | {:user, nil}. Improve typespecs for the Pipeline and apply them where it could be encountered --- lib/pleroma/object/fetcher.ex | 3 +- lib/pleroma/web/activity_pub/pipeline.ex | 19 ++++++---- lib/pleroma/web/common_api.ex | 44 ++++++++++++++---------- 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index ff7aa539f..69a5f3268 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -62,7 +62,8 @@ defmodule Pleroma.Object.Fetcher do :error | :reject | :allowed_depth | :fetch | :containment | :transmogrifier # Note: will create a Create activity, which we need internally at the moment. - @spec fetch_object_from_id(String.t(), list()) :: {:ok, Object.t()} | {fetcher_errors(), any()} + @spec fetch_object_from_id(String.t(), list()) :: + {:ok, Object.t()} | {fetcher_errors(), any()} | Pipeline.errors() def fetch_object_from_id(id, options \\ []) do with {_, nil} <- {:fetch_object, Object.get_cached_by_ap_id(id)}, {_, true} <- {:allowed_depth, Federator.allowed_thread_distance?(options[:depth])}, diff --git a/lib/pleroma/web/activity_pub/pipeline.ex b/lib/pleroma/web/activity_pub/pipeline.ex index 7f11a4d67..fc36935d5 100644 --- a/lib/pleroma/web/activity_pub/pipeline.ex +++ b/lib/pleroma/web/activity_pub/pipeline.ex @@ -22,22 +22,27 @@ defmodule Pleroma.Web.ActivityPub.Pipeline do defp activity_pub, do: Config.get([:pipeline, :activity_pub], ActivityPub) defp config, do: Config.get([:pipeline, :config], Config) - @spec common_pipeline(map(), keyword()) :: - {:ok, Activity.t() | Object.t(), keyword()} | {:error | :reject, any()} + @type results :: {:ok, Activity.t() | Object.t(), keyword()} + @type errors :: {:error | :reject, any()} + + # The Repo.transaction will wrap the result in an {:ok, _} + # and only returns an {:error, _} if the error encountered was related + # to the SQL transaction + @spec common_pipeline(map(), keyword()) :: results() | errors() def common_pipeline(object, meta) do case Repo.transaction(fn -> do_common_pipeline(object, meta) end, Utils.query_timeout()) do {:ok, {:ok, activity, meta}} -> side_effects().handle_after_transaction(meta) {:ok, activity, meta} - {:ok, value} -> - value + {:ok, {:error, _} = error} -> + error + + {:ok, {:reject, _} = error} -> + error {:error, e} -> {:error, e} - - {:reject, e} -> - {:reject, e} end end diff --git a/lib/pleroma/web/common_api.ex b/lib/pleroma/web/common_api.ex index 921e414c3..412424dae 100644 --- a/lib/pleroma/web/common_api.ex +++ b/lib/pleroma/web/common_api.ex @@ -26,7 +26,7 @@ defmodule Pleroma.Web.CommonAPI do require Pleroma.Constants require Logger - @spec block(User.t(), User.t()) :: {:ok, Activity.t()} | {:error, any()} + @spec block(User.t(), User.t()) :: {:ok, Activity.t()} | Pipeline.errors() def block(blocked, blocker) do with {:ok, block_data, _} <- Builder.block(blocker, blocked), {:ok, block, _} <- Pipeline.common_pipeline(block_data, local: true) do @@ -35,7 +35,7 @@ defmodule Pleroma.Web.CommonAPI do end @spec post_chat_message(User.t(), User.t(), String.t(), list()) :: - {:ok, Activity.t()} | {:error, any()} + {:ok, Activity.t()} | Pipeline.errors() 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_attachment_attribution(maybe_attachment, user), @@ -58,7 +58,7 @@ defmodule Pleroma.Web.CommonAPI do )} do {:ok, activity} else - {:common_pipeline, {:reject, _} = e} -> e + {:common_pipeline, e} -> e e -> e end end @@ -99,7 +99,8 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec unblock(User.t(), User.t()) :: {:ok, Activity.t()} | {:error, any()} + @spec unblock(User.t(), User.t()) :: + {:ok, Activity.t()} | {:ok, :no_activity} | Pipeline.errors() | {:error, :not_blocking} def unblock(blocked, blocker) do with {_, %Activity{} = block} <- {:fetch_block, Utils.fetch_latest_block(blocker, blocked)}, {:ok, unblock_data, _} <- Builder.undo(blocker, block), @@ -120,7 +121,9 @@ defmodule Pleroma.Web.CommonAPI do end @spec follow(User.t(), User.t()) :: - {:ok, User.t(), User.t(), Activity.t() | Object.t()} | {:error, :rejected} + {:ok, User.t(), User.t(), Activity.t() | Object.t()} + | {:error, :rejected} + | Pipeline.errors() def follow(followed, follower) do timeout = Pleroma.Config.get([:activitypub, :follow_handshake_timeout]) @@ -145,7 +148,7 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec accept_follow_request(User.t(), User.t()) :: {:ok, User.t()} | {:error, any()} + @spec accept_follow_request(User.t(), User.t()) :: {:ok, User.t()} | Pipeline.errors() def accept_follow_request(follower, followed) do with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), {:ok, accept_data, _} <- Builder.accept(followed, follow_activity), @@ -154,7 +157,7 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec reject_follow_request(User.t(), User.t()) :: {:ok, User.t()} | {:error, any()} | nil + @spec reject_follow_request(User.t(), User.t()) :: {:ok, User.t()} | Pipeline.errors() | nil def reject_follow_request(follower, followed) do with %Activity{} = follow_activity <- Utils.fetch_latest_follow(follower, followed), {:ok, reject_data, _} <- Builder.reject(followed, follow_activity), @@ -163,7 +166,8 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec delete(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, any()} + @spec delete(String.t(), User.t()) :: + {:ok, Activity.t()} | Pipeline.errors() | {:error, :not_found | String.t()} def delete(activity_id, user) do with {_, %Activity{data: %{"object" => _, "type" => "Create"}} = activity} <- {:find_activity, Activity.get_by_id(activity_id, filter: [])}, @@ -213,7 +217,7 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec repeat(String.t(), User.t(), map()) :: {:ok, Activity.t()} | {:error, any()} + @spec repeat(String.t(), User.t(), map()) :: {:ok, Activity.t()} | {:error, :not_found} def repeat(id, user, params \\ %{}) do with %Activity{data: %{"type" => "Create"}} = activity <- Activity.get_by_id(id), object = %Object{} <- Object.normalize(activity, fetch: false), @@ -231,7 +235,7 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec unrepeat(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, any()} + @spec unrepeat(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, :not_found | String.t()} def unrepeat(id, user) do with {_, %Activity{data: %{"type" => "Create"}} = activity} <- {:find_activity, Activity.get_by_id(id)}, @@ -247,7 +251,8 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec favorite(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, any()} + @spec favorite(String.t(), User.t()) :: + {:ok, Activity.t()} | {:ok, :already_liked} | {:error, :not_found | String.t()} def favorite(id, %User{} = user) do case favorite_helper(user, id) do {:ok, _} = res -> @@ -285,7 +290,8 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec unfavorite(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, any()} + @spec unfavorite(String.t(), User.t()) :: + {:ok, Activity.t()} | {:error, :not_found | String.t()} def unfavorite(id, user) do with {_, %Activity{data: %{"type" => "Create"}} = activity} <- {:find_activity, Activity.get_by_id(id)}, @@ -302,7 +308,7 @@ defmodule Pleroma.Web.CommonAPI do end @spec react_with_emoji(String.t(), User.t(), String.t()) :: - {:ok, Activity.t()} | {:error, any()} + {:ok, Activity.t()} | {:error, String.t()} def react_with_emoji(id, user, emoji) do with %Activity{} = activity <- Activity.get_by_id(id), object <- Object.normalize(activity, fetch: false), @@ -316,7 +322,7 @@ defmodule Pleroma.Web.CommonAPI do end @spec unreact_with_emoji(String.t(), User.t(), String.t()) :: - {:ok, Activity.t()} | {:error, any()} + {:ok, Activity.t()} | {:error, String.t()} def unreact_with_emoji(id, user, emoji) do with %Activity{} = reaction_activity <- Utils.get_latest_reaction(id, user, emoji), {_, {:ok, _}} <- {:cancel_jobs, maybe_cancel_jobs(reaction_activity)}, @@ -329,7 +335,7 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec vote(Object.t(), User.t(), list()) :: {:ok, list(), Object.t()} | {:error, any()} + @spec vote(Object.t(), User.t(), list()) :: {:ok, list(), Object.t()} | Pipeline.errors() def vote(%Object{data: %{"type" => "Question"}} = object, %User{} = user, choices) do with :ok <- validate_not_author(object, user), :ok <- validate_existing_votes(user, object), @@ -461,7 +467,7 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec update(Activity.t(), User.t(), map()) :: {:ok, Activity.t()} | {:error, any()} + @spec update(Activity.t(), User.t(), map()) :: {:ok, Activity.t()} | {:error, nil} def update(orig_activity, %User{} = user, changes) do with orig_object <- Object.normalize(orig_activity), {:ok, new_object} <- make_update_data(user, orig_object, changes), @@ -497,7 +503,7 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()} + @spec pin(String.t(), User.t()) :: {:ok, Activity.t()} | Pipeline.errors() def pin(id, %User{} = user) do with %Activity{} = activity <- create_activity_by_id(id), true <- activity_belongs_to_actor(activity, user.ap_id), @@ -537,7 +543,7 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec unpin(String.t(), User.t()) :: {:ok, Activity.t()} | {:error, term()} + @spec unpin(String.t(), User.t()) :: {:ok, Activity.t()} | Pipeline.errors() def unpin(id, user) do with %Activity{} = activity <- create_activity_by_id(id), {:ok, unpin_data, _} <- Builder.unpin(user, activity.object), @@ -552,7 +558,7 @@ defmodule Pleroma.Web.CommonAPI do end end - @spec add_mute(Activity.t(), User.t(), map()) :: {:ok, Activity.t()} | {:error, any()} + @spec add_mute(Activity.t(), User.t(), map()) :: {:ok, Activity.t()} | {:error, String.t()} def add_mute(activity, user, params \\ %{}) do expires_in = Map.get(params, :expires_in, 0) From 7eb579c1911f2eac175c2030f6bb80685b4ab4f8 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 6 Sep 2024 11:18:12 -0400 Subject: [PATCH 087/212] Dialyzer: invalid contract --- lib/pleroma/user/import.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/user/import.ex b/lib/pleroma/user/import.ex index b79fa88eb..ab6bdb8d4 100644 --- a/lib/pleroma/user/import.ex +++ b/lib/pleroma/user/import.ex @@ -12,7 +12,7 @@ defmodule Pleroma.User.Import do require Logger - @spec perform(atom(), User.t(), list()) :: :ok | list() | {:error, any()} + @spec perform(atom(), User.t(), String.t()) :: :ok | {:error, any()} def perform(:mute_import, %User{} = user, actor) do with {:ok, %User{} = muted_user} <- User.get_or_fetch(actor), {_, false} <- {:existing_mute, User.mutes_user?(user, muted_user)}, @@ -49,7 +49,7 @@ defmodule Pleroma.User.Import do defp handle_error(op, user_id, error) do Logger.debug("#{op} failed for #{user_id} with: #{inspect(error)}") - error + {:error, error} end def blocks_import(%User{} = user, [_ | _] = actors) do From 06d6febff960e5aafd44709d0a61311b45892a81 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 6 Sep 2024 11:19:24 -0400 Subject: [PATCH 088/212] Dialyzer: The pattern variable _e@1 can never match the type, because it is covered by previous clauses. --- lib/pleroma/user/backup.ex | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/pleroma/user/backup.ex b/lib/pleroma/user/backup.ex index 7feaa22bf..70cf5b2a1 100644 --- a/lib/pleroma/user/backup.ex +++ b/lib/pleroma/user/backup.ex @@ -92,9 +92,6 @@ defmodule Pleroma.User.Backup do else true -> {:error, "Backup is missing id. Please insert it into the Repo first."} - - e -> - {:error, e} end end From 1d0e3b1355c5a5883be1522f0f925c398c0e87a4 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 6 Sep 2024 11:24:37 -0400 Subject: [PATCH 089/212] Dialyzer: The pattern variable _ can never match the type, because it is covered by previous clauses. --- lib/pleroma/user/backup.ex | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/pleroma/user/backup.ex b/lib/pleroma/user/backup.ex index 70cf5b2a1..c5038c8f4 100644 --- a/lib/pleroma/user/backup.ex +++ b/lib/pleroma/user/backup.ex @@ -294,9 +294,6 @@ defmodule Pleroma.User.Backup do ) acc - - _ -> - acc end end) From 06ce5e3b43b4a6809397bdb0eb192a82e7243e93 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 6 Sep 2024 11:27:07 -0400 Subject: [PATCH 090/212] Dialyzer: pattern_match The pattern can never match the type {:diff, false}. --- lib/pleroma/user/backup.ex | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/pleroma/user/backup.ex b/lib/pleroma/user/backup.ex index c5038c8f4..d77d49890 100644 --- a/lib/pleroma/user/backup.ex +++ b/lib/pleroma/user/backup.ex @@ -118,14 +118,13 @@ defmodule Pleroma.User.Backup do end defp permitted?(user) do - with {_, %__MODULE__{inserted_at: inserted_at}} <- {:last, get_last(user)}, - days = Config.get([__MODULE__, :limit_days]), - diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days), - {_, true} <- {:diff, diff > days} do - true + with {_, %__MODULE__{inserted_at: inserted_at}} <- {:last, get_last(user)} do + days = Config.get([__MODULE__, :limit_days]) + diff = Timex.diff(NaiveDateTime.utc_now(), inserted_at, :days) + + diff > days else {:last, nil} -> true - {:diff, false} -> false end end From 5b26c56624ad281987a18091a6ae245833d2fde1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 6 Sep 2024 11:34:06 -0400 Subject: [PATCH 091/212] Changelog --- changelog.d/dialyzer.skip | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 changelog.d/dialyzer.skip diff --git a/changelog.d/dialyzer.skip b/changelog.d/dialyzer.skip new file mode 100644 index 000000000..e69de29bb From 1afcfd4845fb71e111b7cbcf18858bb100863f8a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 6 Sep 2024 11:51:16 -0400 Subject: [PATCH 092/212] Add tests for Mastodon mention hashtag class --- test/pleroma/html_test.exs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/pleroma/html_test.exs b/test/pleroma/html_test.exs index 1be161971..d17b07540 100644 --- a/test/pleroma/html_test.exs +++ b/test/pleroma/html_test.exs @@ -41,6 +41,10 @@ defmodule Pleroma.HTMLTest do @foo """ + @mention_hashtags_sample """ + + """ + describe "StripTags scrubber" do test "works as expected" do expected = """ @@ -126,6 +130,15 @@ defmodule Pleroma.HTMLTest do Pleroma.HTML.Scrubber.TwitterText ) end + + test "does allow mention hashtags" do + expected = """ + + """ + + assert expected == + HTML.filter_tags(@mention_hashtags_sample, Pleroma.HTML.Scrubber.Default) + end end describe "default scrubber" do @@ -189,6 +202,15 @@ defmodule Pleroma.HTMLTest do Pleroma.HTML.Scrubber.Default ) end + + test "does allow mention hashtags" do + expected = """ + + """ + + assert expected == + HTML.filter_tags(@mention_hashtags_sample, Pleroma.HTML.Scrubber.Default) + end end describe "extract_first_external_url_from_object" do From c9b28eaf9a484fa1f2c27d00855c997575369782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 8 Sep 2024 05:23:46 +0300 Subject: [PATCH 093/212] Argon2 password support --- lib/pleroma/web/plugs/authentication_plug.ex | 4 ++++ mix.exs | 1 + mix.lock | 1 + 3 files changed, 6 insertions(+) diff --git a/lib/pleroma/web/plugs/authentication_plug.ex b/lib/pleroma/web/plugs/authentication_plug.ex index f912a1542..3fc6e7b51 100644 --- a/lib/pleroma/web/plugs/authentication_plug.ex +++ b/lib/pleroma/web/plugs/authentication_plug.ex @@ -47,6 +47,10 @@ defmodule Pleroma.Web.Plugs.AuthenticationPlug do Pleroma.Password.Pbkdf2.verify_pass(password, password_hash) end + def checkpw(password, "$argon2" <> _ = password_hash) do + Argon2.verify_pass(password, password_hash) + end + def checkpw(_password, _password_hash) do Logger.error("Password hash not recognized") false diff --git a/mix.exs b/mix.exs index df44934d7..0d49a6b45 100644 --- a/mix.exs +++ b/mix.exs @@ -203,6 +203,7 @@ defmodule Pleroma.Mixfile do {:websock_adapter, "~> 0.5.6"}, {:oban_live_dashboard, "~> 0.1.1"}, {:multipart, "~> 0.4.0", optional: true}, + {:argon2_elixir, "~> 4.0"}, ## dev & test {:phoenix_live_reload, "~> 1.3.3", only: :dev}, diff --git a/mix.lock b/mix.lock index 865e09a4c..01f2eef98 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,6 @@ %{ "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm", "11b18c220bcc2eab63b5470c038ef10eb6783bcb1fcdb11aa4137defa5ac1bb8"}, + "argon2_elixir": {:hex, :argon2_elixir, "4.0.0", "7f6cd2e4a93a37f61d58a367d82f830ad9527082ff3c820b8197a8a736648941", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f9da27cf060c9ea61b1bd47837a28d7e48a8f6fa13a745e252556c14f9132c7f"}, "bandit": {:hex, :bandit, "1.5.5", "df28f1c41f745401fe9e85a6882033f5f3442ab6d30c8a2948554062a4ab56e0", [:mix], [{:hpax, "~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f21579a29ea4bc08440343b2b5f16f7cddf2fea5725d31b72cf973ec729079e1"}, "base62": {:hex, :base62, "1.2.2", "85c6627eb609317b70f555294045895ffaaeb1758666ab9ef9ca38865b11e629", [:mix], [{:custom_base, "~> 0.2.1", [hex: :custom_base, repo: "hexpm", optional: false]}], "hexpm", "d41336bda8eaa5be197f1e4592400513ee60518e5b9f4dcf38f4b4dae6f377bb"}, "bbcode_pleroma": {:hex, :bbcode_pleroma, "0.2.0", "d36f5bca6e2f62261c45be30fa9b92725c0655ad45c99025cb1c3e28e25803ef", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "19851074419a5fedb4ef49e1f01b30df504bb5dbb6d6adfc135238063bebd1c3"}, From 9de522ce5048bd72dd083a1661506b563be27cc1 Mon Sep 17 00:00:00 2001 From: Mint Date: Sun, 8 Sep 2024 05:32:40 +0300 Subject: [PATCH 094/212] Authentication: convert argon2 passwords, add tests --- lib/pleroma/web/plugs/authentication_plug.ex | 5 ++++ .../web/plugs/authentication_plug_test.exs | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lib/pleroma/web/plugs/authentication_plug.ex b/lib/pleroma/web/plugs/authentication_plug.ex index 3fc6e7b51..af7d7f45a 100644 --- a/lib/pleroma/web/plugs/authentication_plug.ex +++ b/lib/pleroma/web/plugs/authentication_plug.ex @@ -48,6 +48,7 @@ defmodule Pleroma.Web.Plugs.AuthenticationPlug do end def checkpw(password, "$argon2" <> _ = password_hash) do + # Handle argon2 passwords for Akkoma migration Argon2.verify_pass(password, password_hash) end @@ -60,6 +61,10 @@ defmodule Pleroma.Web.Plugs.AuthenticationPlug do do_update_password(user, password) end + def maybe_update_password(%User{password_hash: "$argon2" <> _} = user, password) do + do_update_password(user, password) + end + def maybe_update_password(user, _), do: {:ok, user} defp do_update_password(user, password) do diff --git a/test/pleroma/web/plugs/authentication_plug_test.exs b/test/pleroma/web/plugs/authentication_plug_test.exs index b8acd01c5..bdbf3de32 100644 --- a/test/pleroma/web/plugs/authentication_plug_test.exs +++ b/test/pleroma/web/plugs/authentication_plug_test.exs @@ -70,6 +70,24 @@ defmodule Pleroma.Web.Plugs.AuthenticationPlugTest do assert "$pbkdf2" <> _ = user.password_hash end + test "with an argon2 hash, it updates to a pkbdf2 hash", %{conn: conn} do + user = insert(:user, password_hash: Argon2.hash_pwd_salt("123")) + assert "$argon2" <> _ = user.password_hash + + conn = + conn + |> assign(:auth_user, user) + |> assign(:auth_credentials, %{password: "123"}) + |> AuthenticationPlug.call(%{}) + + assert conn.assigns.user.id == conn.assigns.auth_user.id + assert conn.assigns.token == nil + 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 = @@ -86,6 +104,14 @@ defmodule Pleroma.Web.Plugs.AuthenticationPlugTest do refute AuthenticationPlug.checkpw("password1", hash) end + test "check argon2 hash" do + hash = + "$argon2id$v=19$m=65536,t=8,p=2$zEMMsTuK5KkL5AFWbX7jyQ$VyaQD7PF6e9btz0oH1YiAkWwIGZ7WNDZP8l+a/O171g" + + assert AuthenticationPlug.checkpw("password", hash) + refute AuthenticationPlug.checkpw("password1", hash) + end + test "it returns false when hash invalid" do hash = "psBWV8gxkGOZWBz$PmfCycChoxeJ3GgGzwvhlgacb9mUoZ.KUXNCssekER4SJ7bOK53uXrHNb2e4i8yPFgSKyzaW9CcmrDXWIEMtD1" From 7e91c3a306b8f050e6a88e888fd439b579c1f125 Mon Sep 17 00:00:00 2001 From: Mint Date: Sun, 8 Sep 2024 05:41:48 +0300 Subject: [PATCH 095/212] Changelog --- changelog.d/argon2-passwords.add | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/argon2-passwords.add diff --git a/changelog.d/argon2-passwords.add b/changelog.d/argon2-passwords.add new file mode 100644 index 000000000..36fd7faf2 --- /dev/null +++ b/changelog.d/argon2-passwords.add @@ -0,0 +1 @@ +Added support for argon2 passwords and their conversion for migration from Akkoma fork to upstream. From 7def11d7c352f13ce0f12715649359344cbba9a6 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 11 Sep 2024 12:45:33 -0400 Subject: [PATCH 096/212] LDAP Auth: fix TLS certificate verification Currently we only support STARTTLS and it was not verifying certificate and hostname correctly. We must pass a custom fqdn_fun/1 function so it knows what value to compare against. --- changelog.d/ldap-tls.fix | 1 + lib/pleroma/web/auth/ldap_authenticator.ex | 12 +++++++++++- mix.exs | 1 + 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 changelog.d/ldap-tls.fix diff --git a/changelog.d/ldap-tls.fix b/changelog.d/ldap-tls.fix new file mode 100644 index 000000000..b15137d77 --- /dev/null +++ b/changelog.d/ldap-tls.fix @@ -0,0 +1 @@ +STARTTLS certificate and hostname verification for LDAP authentication diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index ea5620cf6..d31f34747 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -41,6 +41,7 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do port = Keyword.get(ldap, :port, 389) ssl = Keyword.get(ldap, :ssl, false) sslopts = Keyword.get(ldap, :sslopts, []) + tlsopts = Keyword.get(ldap, :tlsopts, []) options = [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}] ++ @@ -54,7 +55,16 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do case :eldap.start_tls( connection, - Keyword.get(ldap, :tlsopts, []), + Keyword.merge( + [ + verify: :verify_peer, + cacerts: :certifi.cacerts(), + customize_hostname_check: [ + fqdn_fun: fn _ -> to_charlist(host) end + ] + ], + tlsopts + ), @connection_timeout ) do :ok -> diff --git a/mix.exs b/mix.exs index 0d49a6b45..9a261547f 100644 --- a/mix.exs +++ b/mix.exs @@ -204,6 +204,7 @@ defmodule Pleroma.Mixfile do {:oban_live_dashboard, "~> 0.1.1"}, {:multipart, "~> 0.4.0", optional: true}, {:argon2_elixir, "~> 4.0"}, + {:certifi, "~> 2.12"}, ## dev & test {:phoenix_live_reload, "~> 1.3.3", only: :dev}, From affdcdb68daabb15f8fad2e7b6406606e8086e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 12 Sep 2024 11:27:29 +0200 Subject: [PATCH 097/212] Manifest: declare /static/logo.svg as 512x512 to match one provided by pleroma-fe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- changelog.d/manifest-icon-size.skip | 0 config/config.exs | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 changelog.d/manifest-icon-size.skip diff --git a/changelog.d/manifest-icon-size.skip b/changelog.d/manifest-icon-size.skip new file mode 100644 index 000000000..e69de29bb diff --git a/config/config.exs b/config/config.exs index 80a3b8d57..cd9a2539f 100644 --- a/config/config.exs +++ b/config/config.exs @@ -344,7 +344,7 @@ config :pleroma, :manifest, icons: [ %{ src: "/static/logo.svg", - sizes: "144x144", + sizes: "512x512", purpose: "any", type: "image/svg+xml" } From 17b69c43d5ed6ba867f5fb1da15f6af9aa7c5d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 12 Sep 2024 14:37:37 +0200 Subject: [PATCH 098/212] Add `group_key` to notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- changelog.d/notifications-group-key.add | 1 + .../api_spec/operations/notification_operation.ex | 5 +++++ .../web/mastodon_api/views/notification_view.ex | 1 + .../mastodon_api/views/notification_view_test.exs | 13 +++++++++++++ 4 files changed, 20 insertions(+) create mode 100644 changelog.d/notifications-group-key.add diff --git a/changelog.d/notifications-group-key.add b/changelog.d/notifications-group-key.add new file mode 100644 index 000000000..386927f4a --- /dev/null +++ b/changelog.d/notifications-group-key.add @@ -0,0 +1 @@ +Add `group_key` to notifications \ No newline at end of file diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex index 2dc0f66df..94d1f6b82 100644 --- a/lib/pleroma/web/api_spec/operations/notification_operation.ex +++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex @@ -158,6 +158,10 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do type: :object, properties: %{ id: %Schema{type: :string}, + group_key: %Schema{ + type: :string, + description: "Group key shared by similar notifications" + }, type: notification_type(), created_at: %Schema{type: :string, format: :"date-time"}, account: %Schema{ @@ -180,6 +184,7 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do }, example: %{ "id" => "34975861", + "group-key" => "ungrouped-34975861", "type" => "mention", "created_at" => "2019-11-23T07:49:02.064Z", "account" => Account.schema().example, diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex index 3f2478719..c277af98b 100644 --- a/lib/pleroma/web/mastodon_api/views/notification_view.ex +++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex @@ -95,6 +95,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do response = %{ id: to_string(notification.id), + group_key: "ungrouped-" <> to_string(notification.id), type: notification.type, created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), account: account, diff --git a/test/pleroma/web/mastodon_api/views/notification_view_test.exs b/test/pleroma/web/mastodon_api/views/notification_view_test.exs index 75ab375aa..b1f3523ac 100644 --- a/test/pleroma/web/mastodon_api/views/notification_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/notification_view_test.exs @@ -56,6 +56,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "pleroma:chat_mention", account: AccountView.render("show.json", %{user: user, for: recipient}), @@ -75,6 +76,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "mention", account: @@ -99,6 +101,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "favourite", account: AccountView.render("show.json", %{user: another_user, for: user}), @@ -119,6 +122,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "reblog", account: AccountView.render("show.json", %{user: another_user, for: user}), @@ -137,6 +141,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "follow", account: AccountView.render("show.json", %{user: follower, for: followed}), @@ -165,6 +170,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "move", account: AccountView.render("show.json", %{user: old_user, for: follower}), @@ -190,6 +196,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "pleroma:emoji_reaction", emoji: "☕", @@ -229,6 +236,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "pleroma:emoji_reaction", emoji: ":dinosaur:", @@ -248,6 +256,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "poll", account: @@ -274,6 +283,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "pleroma:report", account: AccountView.render("show.json", %{user: reporting_user, for: moderator_user}), @@ -300,6 +310,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "update", account: AccountView.render("show.json", %{user: user, for: repeat_user}), @@ -322,6 +333,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: true, is_muted: true}, type: "favourite", account: AccountView.render("show.json", %{user: another_user, for: user}), @@ -345,6 +357,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do expected = %{ id: to_string(notification.id), + group_key: "ungrouped-#{to_string(notification.id)}", pleroma: %{is_seen: false, is_muted: false}, type: "status", account: From e10db52e0a1c9cc24803a406998a9cfe75b7f9f2 Mon Sep 17 00:00:00 2001 From: Mint Date: Fri, 13 Sep 2024 02:58:59 +0300 Subject: [PATCH 099/212] Add dependencies for Swoosh's Mua mail adapter --- changelog.d/swoosh-mua.add | 1 + mix.exs | 4 +++- mix.lock | 4 +++- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 changelog.d/swoosh-mua.add diff --git a/changelog.d/swoosh-mua.add b/changelog.d/swoosh-mua.add new file mode 100644 index 000000000..d4c4bbd08 --- /dev/null +++ b/changelog.d/swoosh-mua.add @@ -0,0 +1 @@ +Added dependencies for Swoosh's Mua mail adapter diff --git a/mix.exs b/mix.exs index 0d49a6b45..ceae5c26d 100644 --- a/mix.exs +++ b/mix.exs @@ -153,7 +153,7 @@ defmodule Pleroma.Mixfile do {:calendar, "~> 1.0"}, {:cachex, "~> 3.2"}, {:tesla, "~> 1.11"}, - {:castore, "~> 0.1"}, + {:castore, "~> 1.0"}, {:cowlib, "~> 2.9", override: true}, {:gun, "~> 2.0.0-rc.1", override: true}, {:finch, "~> 0.15"}, @@ -169,6 +169,8 @@ defmodule Pleroma.Mixfile do {:swoosh, "~> 1.16.9"}, {:phoenix_swoosh, "~> 1.1"}, {:gen_smtp, "~> 0.13"}, + {:mua, "~> 0.2.0"}, + {:mail, "~> 0.3.0"}, {:ex_syslogger, "~> 1.4"}, {:floki, "~> 0.35"}, {:timex, "~> 3.6"}, diff --git a/mix.lock b/mix.lock index 01f2eef98..2cf44862b 100644 --- a/mix.lock +++ b/mix.lock @@ -11,7 +11,7 @@ "cachex": {:hex, :cachex, "3.6.0", "14a1bfbeee060dd9bec25a5b6f4e4691e3670ebda28c8ba2884b12fe30b36bf8", [: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", "ebf24e373883bc8e0c8d894a63bbe102ae13d918f790121f5cfe6e485cc8e2e2"}, "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", "6630c42aaaab124e697b4e513190c89d8b64e410", [ref: "6630c42aaaab124e697b4e513190c89d8b64e410"]}, - "castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"}, + "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.9", "e8d3364f310da6ce6463c3dd20cf90ae7bbecbf6c5203b98bf9b48035592649b", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9dcab3d0f3038621f1601f13539e7a9ee99843862e66ad62827b0c42b2f58a54"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, @@ -72,6 +72,7 @@ "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, "linkify": {:hex, :linkify, "0.5.3", "5f8143d8f61f5ff08d3aeeff47ef6509492b4948d8f08007fbf66e4d2246a7f2", [:mix], [], "hexpm", "3ef35a1377d47c25506e07c1c005ea9d38d700699d92ee92825f024434258177"}, "logger_backends": {:hex, :logger_backends, "1.0.0", "09c4fad6202e08cb0fbd37f328282f16539aca380f512523ce9472b28edc6bdf", [:mix], [], "hexpm", "1faceb3e7ec3ef66a8f5746c5afd020e63996df6fd4eb8cdb789e5665ae6c9ce"}, + "mail": {:hex, :mail, "0.3.1", "cb0a14e4ed8904e4e5a08214e686ccf6f9099346885db17d8c309381f865cc5c", [:mix], [], "hexpm", "1db701e89865c1d5fa296b2b57b1cd587587cca8d8a1a22892b35ef5a8e352a6"}, "majic": {:hex, :majic, "1.0.0", "37e50648db5f5c2ff0c9fb46454d034d11596c03683807b9fb3850676ffdaab3", [:make, :mix], [{:elixir_make, "~> 0.6.1", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "7905858f76650d49695f14ea55cd9aaaee0c6654fa391671d4cf305c275a0a9e"}, "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, @@ -85,6 +86,7 @@ "mock": {:hex, :mock, "0.3.8", "7046a306b71db2488ef54395eeb74df0a7f335a7caca4a3d3875d1fc81c884dd", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "7fa82364c97617d79bb7d15571193fc0c4fe5afd0c932cef09426b3ee6fe2022"}, "mogrify": {:hex, :mogrify, "0.9.3", "238c782f00271dace01369ad35ae2e9dd020feee3443b9299ea5ea6bed559841", [:mix], [], "hexpm", "0189b1e1de27455f2b9ae8cf88239cefd23d38de9276eb5add7159aea51731e6"}, "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, + "mua": {:hex, :mua, "0.2.3", "46b29b7b2bb14105c0b7be9526f7c452df17a7841b30b69871c024a822ff551c", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "7fe861a87fcc06a980d3941bbcb2634e5f0f30fd6ad15ef6c0423ff9dc7e46de"}, "multipart": {:hex, :multipart, "0.4.0", "634880a2148d4555d050963373d0e3bbb44a55b2badd87fa8623166172e9cda0", [:mix], [{:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm", "3c5604bc2fb17b3137e5d2abdf5dacc2647e60c5cc6634b102cf1aef75a06f0a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, From 1a120d013019fc15b3f440f7db71d3eb328bc798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 14 Sep 2024 20:17:08 +0200 Subject: [PATCH 100/212] Federate avatar/header descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- changelog.d/profile-image-descriptions.skip | 0 lib/pleroma/user.ex | 5 +++ lib/pleroma/web/activity_pub/activity_pub.ex | 9 ++++- .../web/activity_pub/views/user_view.ex | 36 +++++++++++++++---- .../web/mastodon_api/views/account_view.ex | 8 ++--- .../web/activity_pub/activity_pub_test.exs | 35 ++++++++++++++++-- .../web/activity_pub/views/user_view_test.exs | 17 +++++++++ 7 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 changelog.d/profile-image-descriptions.skip diff --git a/changelog.d/profile-image-descriptions.skip b/changelog.d/profile-image-descriptions.skip new file mode 100644 index 000000000..e69de29bb diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 517009253..7a36ece77 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -419,6 +419,11 @@ defmodule Pleroma.User do end end + def image_description(image, default \\ "") + + def image_description(%{"name" => name}, _default), do: name + def image_description(_, default), do: default + # Should probably be renamed or removed @spec ap_id(User.t()) :: String.t() def ap_id(%User{nickname: nickname}), do: "#{Endpoint.url()}/users/#{nickname}" diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index a2a94a0ff..df8795fe4 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1542,16 +1542,23 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do defp get_actor_url(_url), do: nil - defp normalize_image(%{"url" => url}) do + defp normalize_image(%{"url" => url} = data) do %{ "type" => "Image", "url" => [%{"href" => url}] } + |> maybe_put_description(data) end defp normalize_image(urls) when is_list(urls), do: urls |> List.first() |> normalize_image() defp normalize_image(_), do: nil + defp maybe_put_description(map, %{"name" => description}) when is_binary(description) do + Map.put(map, "name", description) + end + + defp maybe_put_description(map, _), do: map + defp object_to_user_data(data, additional) do fields = data diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 937e4fd67..cd485ed64 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -129,8 +129,22 @@ defmodule Pleroma.Web.ActivityPub.UserView do "vcard:bday" => birthday, "webfinger" => "acct:#{User.full_nickname(user)}" } - |> Map.merge(maybe_make_image(&User.avatar_url/2, "icon", user)) - |> Map.merge(maybe_make_image(&User.banner_url/2, "image", user)) + |> Map.merge( + maybe_make_image( + &User.avatar_url/2, + User.image_description(user.avatar, nil), + "icon", + user + ) + ) + |> Map.merge( + maybe_make_image( + &User.banner_url/2, + User.image_description(user.banner, nil), + "image", + user + ) + ) |> Map.merge(Utils.make_json_ld_header()) end @@ -305,16 +319,24 @@ defmodule Pleroma.Web.ActivityPub.UserView do end end - defp maybe_make_image(func, key, user) do + defp maybe_make_image(func, description, key, user) do if image = func.(user, no_default: true) do %{ - key => %{ - "type" => "Image", - "url" => image - } + key => + %{ + "type" => "Image", + "url" => image + } + |> maybe_put_description(description) } else %{} end end + + defp maybe_put_description(map, description) when is_binary(description) do + Map.put(map, "name", description) + end + + defp maybe_put_description(map, _description), do: map end diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 7de6745d4..f6727d29d 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -219,10 +219,10 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do avatar = User.avatar_url(user) |> MediaProxy.url() avatar_static = User.avatar_url(user) |> MediaProxy.preview_url(static: true) - avatar_description = image_description(user.avatar) + avatar_description = User.image_description(user.avatar) header = User.banner_url(user) |> MediaProxy.url() header_static = User.banner_url(user) |> MediaProxy.preview_url(static: true) - header_description = image_description(user.banner) + header_description = User.image_description(user.banner) following_count = if !user.hide_follows_count or !user.hide_follows or self, @@ -349,10 +349,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do defp username_from_nickname(_), do: nil - defp image_description(%{"name" => name}), do: name - - defp image_description(_), do: "" - defp maybe_put_follow_requests_count( data, %User{id: user_id} = user, diff --git a/test/pleroma/web/activity_pub/activity_pub_test.exs b/test/pleroma/web/activity_pub/activity_pub_test.exs index b4f6fb68a..72222ae88 100644 --- a/test/pleroma/web/activity_pub/activity_pub_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_test.exs @@ -232,12 +232,14 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do assert user.avatar == %{ "type" => "Image", - "url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}] + "url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}], + "name" => "profile picture" } assert user.banner == %{ "type" => "Image", - "url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}] + "url" => [%{"href" => "https://jk.nipponalba.scot/images/profile.jpg"}], + "name" => "profile picture" } end @@ -432,6 +434,35 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubTest do assert user.birthday == ~D[2001-02-12] end + + test "fetches avatar description" do + user_id = "https://example.com/users/marcin" + + user_data = + "test/fixtures/users_mock/user.json" + |> File.read!() + |> String.replace("{{nickname}}", "marcin") + |> Jason.decode!() + |> Map.delete("featured") + |> Map.update("icon", %{}, fn image -> Map.put(image, "name", "image description") end) + |> Jason.encode!() + + Tesla.Mock.mock(fn + %{ + method: :get, + url: ^user_id + } -> + %Tesla.Env{ + status: 200, + body: user_data, + headers: [{"content-type", "application/activity+json"}] + } + end) + + {:ok, user} = ActivityPub.make_user_from_ap_id(user_id) + + assert user.avatar["name"] == "image description" + end end test "it fetches the appropriate tag-restricted posts" do diff --git a/test/pleroma/web/activity_pub/views/user_view_test.exs b/test/pleroma/web/activity_pub/views/user_view_test.exs index 651e535ac..a32e72829 100644 --- a/test/pleroma/web/activity_pub/views/user_view_test.exs +++ b/test/pleroma/web/activity_pub/views/user_view_test.exs @@ -68,6 +68,23 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do result = UserView.render("user.json", %{user: user}) assert result["icon"]["url"] == "https://someurl" assert result["image"]["url"] == "https://somebanner" + + refute result["icon"]["name"] + refute result["image"]["name"] + end + + test "Avatar has a description if the user set one" do + user = + insert(:user, + avatar: %{ + "url" => [%{"href" => "https://someurl"}], + "name" => "a drawing of pleroma-tan using pleroma groups" + } + ) + + result = UserView.render("user.json", %{user: user}) + + assert result["icon"]["name"] == "a drawing of pleroma-tan using pleroma groups" end test "renders an invisible user with the invisible property set to true" do From 5539fea3bb0d272b4cefc2b72755cb3cd285cc67 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sat, 14 Sep 2024 20:03:26 -0400 Subject: [PATCH 101/212] LDAP: permit overriding the CA root --- changelog.d/ldap-ca.add | 1 + config/config.exs | 4 +++- docs/configuration/cheatsheet.md | 1 + lib/pleroma/web/auth/ldap_authenticator.ex | 17 ++++++++++++++++- mix.exs | 1 - 5 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 changelog.d/ldap-ca.add diff --git a/changelog.d/ldap-ca.add b/changelog.d/ldap-ca.add new file mode 100644 index 000000000..32ecbb5c0 --- /dev/null +++ b/changelog.d/ldap-ca.add @@ -0,0 +1 @@ +LDAP configuration now permits overriding the CA root certificate file for TLS validation. diff --git a/config/config.exs b/config/config.exs index 80a3b8d57..237928503 100644 --- a/config/config.exs +++ b/config/config.exs @@ -619,7 +619,9 @@ config :pleroma, :ldap, tls: System.get_env("LDAP_TLS") == "true", tlsopts: [], base: System.get_env("LDAP_BASE") || "dc=example,dc=com", - uid: System.get_env("LDAP_UID") || "cn" + uid: System.get_env("LDAP_UID") || "cn", + # defaults to CAStore's Mozilla roots + cacertfile: nil oauth_consumer_strategies = System.get_env("OAUTH_CONSUMER_STRATEGIES") diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 0b4e53b6f..4cbde696e 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -974,6 +974,7 @@ Pleroma account will be created with the same name as the LDAP user name. * `tlsopts`: additional TLS options * `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" +* `cacertfile`: Path to alternate CA root certificates file 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"`. diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index d31f34747..7f2cd3d69 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -42,11 +42,14 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do ssl = Keyword.get(ldap, :ssl, false) sslopts = Keyword.get(ldap, :sslopts, []) tlsopts = Keyword.get(ldap, :tlsopts, []) + cacertfile = Keyword.get(ldap, :cacertfile) || CAStore.file_path() options = [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}] ++ if sslopts != [], do: [{:sslopts, sslopts}], else: [] + cacerts = decode_certfile(cacertfile) + case :eldap.open([to_charlist(host)], options) do {:ok, connection} -> try do @@ -58,7 +61,7 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do Keyword.merge( [ verify: :verify_peer, - cacerts: :certifi.cacerts(), + cacerts: cacerts, customize_hostname_check: [ fqdn_fun: fn _ -> to_charlist(host) end ] @@ -147,4 +150,16 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do error -> error end end + + defp decode_certfile(file) do + with {:ok, data} <- File.read(file) do + data + |> :public_key.pem_decode() + |> Enum.map(fn {_, b, _} -> b end) + else + _ -> + Logger.error("Unable to read certfile: #{file}") + [] + end + end end diff --git a/mix.exs b/mix.exs index 9a261547f..0d49a6b45 100644 --- a/mix.exs +++ b/mix.exs @@ -204,7 +204,6 @@ defmodule Pleroma.Mixfile do {:oban_live_dashboard, "~> 0.1.1"}, {:multipart, "~> 0.4.0", optional: true}, {:argon2_elixir, "~> 4.0"}, - {:certifi, "~> 2.12"}, ## dev & test {:phoenix_live_reload, "~> 1.3.3", only: :dev}, From af3bf8a4628c0b2981d69f624e3be298adc7dfe6 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 15 Sep 2024 13:56:16 -0400 Subject: [PATCH 102/212] Support implicit TLS connections Update docs to clarify that the :ssl option is also for modern TLS, but the :tls option is only for STARTTLS These options may benefit from being renamed but they match upstream terminology. --- changelog.d/ldaps.fix | 1 + docs/configuration/cheatsheet.md | 4 +- lib/pleroma/web/auth/ldap_authenticator.ex | 50 ++++++++++++---------- 3 files changed, 31 insertions(+), 24 deletions(-) create mode 100644 changelog.d/ldaps.fix diff --git a/changelog.d/ldaps.fix b/changelog.d/ldaps.fix new file mode 100644 index 000000000..a1dc901ab --- /dev/null +++ b/changelog.d/ldaps.fix @@ -0,0 +1 @@ +LDAPS connections (implicit TLS) are now supported. diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 4cbde696e..6a535e054 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -968,9 +968,9 @@ Pleroma account will be created with the same name as the LDAP user name. * `enabled`: enables LDAP authentication * `host`: LDAP server hostname * `port`: LDAP port, e.g. 389 or 636 -* `ssl`: true to use SSL, usually implies the port 636 +* `ssl`: true to use implicit SSL/TLS, usually port 636 * `sslopts`: additional SSL options -* `tls`: true to start TLS, usually implies the port 389 +* `tls`: true to use explicit TLS (STARTTLS), usually port 389 * `tlsopts`: additional TLS options * `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" diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index 7f2cd3d69..18a4e81ee 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -40,34 +40,39 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do host = Keyword.get(ldap, :host, "localhost") port = Keyword.get(ldap, :port, 389) ssl = Keyword.get(ldap, :ssl, false) - sslopts = Keyword.get(ldap, :sslopts, []) - tlsopts = Keyword.get(ldap, :tlsopts, []) + tls = Keyword.get(ldap, :tls, false) cacertfile = Keyword.get(ldap, :cacertfile) || CAStore.file_path() - options = - [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}] ++ - if sslopts != [], do: [{:sslopts, sslopts}], else: [] + default_secure_opts = [ + verify: :verify_peer, + cacerts: decode_certfile(cacertfile), + customize_hostname_check: [ + fqdn_fun: fn _ -> to_charlist(host) end + ] + ] - cacerts = decode_certfile(cacertfile) + sslopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :sslopts, [])) + tlsopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :tlsopts, [])) + + # :sslopts can only be included in :eldap.open/2 when {ssl: true} + # or the connection will fail + options = + if ssl do + [{:port, port}, {:ssl, ssl}, {:sslopts, sslopts}, {:timeout, @connection_timeout}] + else + [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}] + end case :eldap.open([to_charlist(host)], options) do {:ok, connection} -> - try do - if Keyword.get(ldap, :tls, false) do + cond do + ssl -> :application.ensure_all_started(:ssl) + tls -> case :eldap.start_tls( connection, - Keyword.merge( - [ - verify: :verify_peer, - cacerts: cacerts, - customize_hostname_check: [ - fqdn_fun: fn _ -> to_charlist(host) end - ] - ], - tlsopts - ), + tlsopts, @connection_timeout ) do :ok -> @@ -75,14 +80,15 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do error -> Logger.error("Could not start TLS: #{inspect(error)}") + :eldap.close(connection) end - end - bind_user(connection, ldap, name, password) - after - :eldap.close(connection) + true -> + :ok end + bind_user(connection, ldap, name, password) + {:error, error} -> Logger.error("Could not open LDAP connection: #{inspect(error)}") {:error, {:ldap_connection_error, error}} From 91d1d7260b7084f59ae42e7c4b46c7fb963fda96 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 15 Sep 2024 23:18:17 -0400 Subject: [PATCH 103/212] Retain the try do so an LDAP failure can fall back to local database. This fixes tests but the automatic fallback may not be well documented behavior. --- lib/pleroma/web/auth/ldap_authenticator.ex | 42 ++++++++++++---------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index 18a4e81ee..ad5bc9863 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -65,30 +65,34 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do case :eldap.open([to_charlist(host)], options) do {:ok, connection} -> - cond do - ssl -> - :application.ensure_all_started(:ssl) + try do + cond do + ssl -> + :application.ensure_all_started(:ssl) - tls -> - case :eldap.start_tls( - connection, - tlsopts, - @connection_timeout - ) do - :ok -> - :ok + tls -> + case :eldap.start_tls( + connection, + tlsopts, + @connection_timeout + ) do + :ok -> + :ok - error -> - Logger.error("Could not start TLS: #{inspect(error)}") - :eldap.close(connection) - end + error -> + Logger.error("Could not start TLS: #{inspect(error)}") + :eldap.close(connection) + end - true -> - :ok + true -> + :ok + end + + bind_user(connection, ldap, name, password) + after + :eldap.close(connection) end - bind_user(connection, ldap, name, password) - {:error, error} -> Logger.error("Could not open LDAP connection: #{inspect(error)}") {:error, {:ldap_connection_error, error}} From e74e0089bf2943f925cbead14154f8b2fa207963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Mon, 16 Sep 2024 17:07:39 +0200 Subject: [PATCH 104/212] Repesct :restrict_unauthenticated for hashtag rss/atom feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- changelog.d/hashtag-feeds-restricted.add | 1 + lib/pleroma/web/feed/tag_controller.ex | 6 +- test/pleroma/web/feed/tag_controller_test.exs | 56 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 changelog.d/hashtag-feeds-restricted.add diff --git a/changelog.d/hashtag-feeds-restricted.add b/changelog.d/hashtag-feeds-restricted.add new file mode 100644 index 000000000..accac9c9c --- /dev/null +++ b/changelog.d/hashtag-feeds-restricted.add @@ -0,0 +1 @@ +Repesct :restrict_unauthenticated for hashtag rss/atom feeds \ No newline at end of file diff --git a/lib/pleroma/web/feed/tag_controller.ex b/lib/pleroma/web/feed/tag_controller.ex index e60767327..02d639296 100644 --- a/lib/pleroma/web/feed/tag_controller.ex +++ b/lib/pleroma/web/feed/tag_controller.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.Feed.TagController do alias Pleroma.Web.Feed.FeedView def feed(conn, params) do - if Config.get!([:instance, :public]) do + if not Config.restrict_unauthenticated_access?(:timelines, :local) do render_feed(conn, params) else render_error(conn, :not_found, "Not found") @@ -18,10 +18,12 @@ defmodule Pleroma.Web.Feed.TagController do end defp render_feed(conn, %{"tag" => raw_tag} = params) do + local_only = Config.restrict_unauthenticated_access?(:timelines, :federated) + {format, tag} = parse_tag(raw_tag) activities = - %{type: ["Create"], tag: tag} + %{type: ["Create"], tag: tag, local_only: local_only} |> Pleroma.Maps.put_if_present(:max_id, params["max_id"]) |> ActivityPub.fetch_public_activities() diff --git a/test/pleroma/web/feed/tag_controller_test.exs b/test/pleroma/web/feed/tag_controller_test.exs index 7d196b228..662235f31 100644 --- a/test/pleroma/web/feed/tag_controller_test.exs +++ b/test/pleroma/web/feed/tag_controller_test.exs @@ -191,4 +191,60 @@ defmodule Pleroma.Web.Feed.TagControllerTest do |> response(404) end end + + describe "restricted for unauthenticated" do + test "returns 404 when local timeline is disabled", %{conn: conn} do + clear_config([:restrict_unauthenticated, :timelines], %{local: true, federated: false}) + + conn + |> put_req_header("accept", "application/rss+xml") + |> get(tag_feed_path(conn, :feed, "pleromaart.rss")) + |> response(404) + end + + test "returns local posts only when federated timeline is disabled", %{conn: conn} do + clear_config([:restrict_unauthenticated, :timelines], %{local: false, federated: true}) + + local_user = insert(:user) + remote_user = insert(:user, local: false) + + local_note = + insert(:note, + user: local_user, + data: %{ + "content" => "local post #PleromaArt", + "summary" => "", + "tag" => ["pleromaart"] + } + ) + + remote_note = + insert(:note, + user: remote_user, + data: %{ + "content" => "remote post #PleromaArt", + "summary" => "", + "tag" => ["pleromaart"] + }, + local: false + ) + + insert(:note_activity, user: local_user, note: local_note) + insert(:note_activity, user: remote_user, note: remote_note, local: false) + + response = + conn + |> put_req_header("accept", "application/rss+xml") + |> get(tag_feed_path(conn, :feed, "pleromaart.rss")) + |> response(200) + + xml = parse(response) + + assert xpath(xml, ~x"//channel/title/text()") == ~c"#pleromaart" + + assert xpath(xml, ~x"//channel/item/title/text()"l) == [ + ~c"local post #PleromaArt" + ] + end + end end From e59706c201bd71525c0a15008c3cb5dcdfb73289 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 16 Sep 2024 11:39:19 -0400 Subject: [PATCH 105/212] Reapply "Custom mix task to retry failed tests once in CI pipeline" This reverts commit b281ad06de2de331450a5e319e3ba497071d4197. --- .gitlab-ci.yml | 2 +- changelog.d/fix-test-failures.skip | 0 lib/mix/tasks/pleroma/test_runner.ex | 25 +++++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) delete mode 100644 changelog.d/fix-test-failures.skip create mode 100644 lib/mix/tasks/pleroma/test_runner.ex diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1e04dae76..76d1a4210 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -134,7 +134,7 @@ unit-testing-1.13.4-otp-25: script: &testing_script - mix ecto.create - mix ecto.migrate - - mix test --cover --preload-modules + - mix pleroma.test_runner --cover --preload-modules coverage: '/^Line total: ([^ ]*%)$/' artifacts: reports: diff --git a/changelog.d/fix-test-failures.skip b/changelog.d/fix-test-failures.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/lib/mix/tasks/pleroma/test_runner.ex b/lib/mix/tasks/pleroma/test_runner.ex new file mode 100644 index 000000000..69fefb001 --- /dev/null +++ b/lib/mix/tasks/pleroma/test_runner.ex @@ -0,0 +1,25 @@ +defmodule Mix.Tasks.Pleroma.TestRunner do + @shortdoc "Retries tests once if they fail" + + use Mix.Task + + def run(args \\ []) do + case System.cmd("mix", ["test"] ++ args, into: IO.stream(:stdio, :line)) do + {_, 0} -> + :ok + + _ -> + retry(args) + end + end + + def retry(args) do + case System.cmd("mix", ["test", "--failed"] ++ args, into: IO.stream(:stdio, :line)) do + {_, 0} -> + :ok + + _ -> + exit(1) + end + end +end From 9264b21907f5c6890694d6d611ade9b13433463a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 16 Sep 2024 00:26:57 -0400 Subject: [PATCH 106/212] Pleroma.LDAP This adds a GenServer which will keep an LDAP connection open and auto reconnect on failure with a 5 second wait between retries. Another benefit is this prevents parsing the Root CAs for every login attempt as we only need to do it once per connection. --- lib/pleroma/application.ex | 1 + lib/pleroma/ldap.ex | 233 +++++++++++++++++++++ lib/pleroma/web/auth/ldap_authenticator.ex | 147 +------------ 3 files changed, 236 insertions(+), 145 deletions(-) create mode 100644 lib/pleroma/ldap.ex diff --git a/lib/pleroma/application.ex b/lib/pleroma/application.ex index cb15dc1e9..3f199c002 100644 --- a/lib/pleroma/application.ex +++ b/lib/pleroma/application.ex @@ -94,6 +94,7 @@ defmodule Pleroma.Application do children = [ Pleroma.PromEx, + Pleroma.LDAP, Pleroma.Repo, Config.TransferTask, Pleroma.Emoji, diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex new file mode 100644 index 000000000..868932501 --- /dev/null +++ b/lib/pleroma/ldap.ex @@ -0,0 +1,233 @@ +defmodule Pleroma.LDAP do + use GenServer + + require Logger + + alias Pleroma.Config + alias Pleroma.User + + import Pleroma.Web.Auth.Helpers, only: [fetch_user: 1] + + @connection_timeout 10_000 + @search_timeout 10_000 + + def start_link(_) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @impl true + def init(state) do + case {Config.get(Pleroma.Web.Auth.Authenticator), Config.get([:ldap, :enabled])} do + {Pleroma.Web.Auth.LDAPAuthenticator, true} -> + {:ok, state, {:continue, :connect}} + + {Pleroma.Web.Auth.LDAPAuthenticator, false} -> + Logger.error( + "LDAP Authenticator enabled but :pleroma, :ldap is not enabled. Auth will not work." + ) + + {:ok, state} + + {_, true} -> + Logger.warning( + ":pleroma, :ldap is enabled but Pleroma.Web.Authenticator is not set to the LDAPAuthenticator. LDAP will not be used." + ) + + {:ok, state} + end + end + + @impl true + def handle_continue(:connect, _state), do: do_handle_connect() + + @impl true + def handle_info(:connect, _state), do: do_handle_connect() + + def handle_info({:bind_after_reconnect, name, password, from}, state) do + result = bind_user(state[:connection], name, password) + + GenServer.reply(from, result) + + {:noreply, state} + end + + defp do_handle_connect() do + state = + case connect() do + {:ok, connection} -> + :eldap.controlling_process(connection, self()) + [connection: connection] + + _ -> + Logger.error("Failed to connect to LDAP. Retrying in 5000ms") + Process.send_after(self(), :connect, 5_000) + [] + end + + {:noreply, state} + end + + @impl true + def handle_call({:bind_user, name, password}, from, state) do + case bind_user(state[:connection], name, password) do + :needs_reconnect -> + Process.send(self(), {:bind_after_reconnect, name, password, from}, []) + {:noreply, state, {:continue, :connect}} + + result -> + {:reply, result, state, :hibernate} + end + end + + @impl true + def terminate(_, state) do + :eldap.close(state[:connection]) + + :ok + end + + defp connect() do + ldap = Config.get(:ldap, []) + host = Keyword.get(ldap, :host, "localhost") + port = Keyword.get(ldap, :port, 389) + ssl = Keyword.get(ldap, :ssl, false) + tls = Keyword.get(ldap, :tls, false) + cacertfile = Keyword.get(ldap, :cacertfile) || CAStore.file_path() + + default_secure_opts = [ + verify: :verify_peer, + cacerts: decode_certfile(cacertfile), + customize_hostname_check: [ + fqdn_fun: fn _ -> to_charlist(host) end + ] + ] + + sslopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :sslopts, [])) + tlsopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :tlsopts, [])) + + default_options = [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}] + + # :sslopts can only be included in :eldap.open/2 when {ssl: true} + # or the connection will fail + options = + if ssl do + default_options ++ [{:sslopts, sslopts}] + else + default_options + end + + case :eldap.open([to_charlist(host)], options) do + {:ok, connection} -> + try do + cond do + ssl -> + :application.ensure_all_started(:ssl) + {:ok, connection} + + tls -> + case :eldap.start_tls( + connection, + tlsopts, + @connection_timeout + ) do + :ok -> + {:ok, connection} + + error -> + Logger.error("Could not start TLS: #{inspect(error)}") + :eldap.close(connection) + end + + true -> + {:ok, :connection} + end + after + :ok + end + + {:error, error} -> + Logger.error("Could not open LDAP connection: #{inspect(error)}") + {:error, {:ldap_connection_error, error}} + end + end + + defp bind_user(connection, name, password) do + uid = Config.get([:ldap, :uid], "cn") + base = Config.get([:ldap, :base]) + + case :eldap.simple_bind(connection, "#{uid}=#{name},#{base}", password) do + :ok -> + case fetch_user(name) do + %User{} = user -> + user + + _ -> + register_user(connection, base, uid, name) + end + + # eldap does not inform us of socket closure + # until it is used + {:error, {:gen_tcp_error, :closed}} -> + :eldap.close(connection) + :needs_reconnect + + error -> + Logger.error("Could not bind LDAP user #{name}: #{inspect(error)}") + {:error, {:ldap_bind_error, error}} + end + end + + 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))}, + {:scope, :eldap.wholeSubtree()}, + {:timeout, @search_timeout} + ]) do + # The :eldap_search_result record structure changed in OTP 24.3 and added a controls field + # https://github.com/erlang/otp/pull/5538 + {:ok, {:eldap_search_result, [{:eldap_entry, _object, attributes}], _referrals}} -> + try_register(name, attributes) + + {:ok, {:eldap_search_result, [{:eldap_entry, _object, attributes}], _referrals, _controls}} -> + try_register(name, attributes) + + error -> + Logger.error("Couldn't register user because LDAP search failed: #{inspect(error)}") + {:error, {:ldap_search_error, error}} + end + end + + defp try_register(name, attributes) do + params = %{ + name: name, + nickname: name, + password: nil + } + + params = + case List.keyfind(attributes, ~c"mail", 0) do + {_, [mail]} -> Map.put_new(params, :email, :erlang.list_to_binary(mail)) + _ -> params + end + + changeset = User.register_changeset_ldap(%User{}, params) + + case User.register(changeset) do + {:ok, user} -> user + error -> error + end + end + + defp decode_certfile(file) do + with {:ok, data} <- File.read(file) do + data + |> :public_key.pem_decode() + |> Enum.map(fn {_, b, _} -> b end) + else + _ -> + Logger.error("Unable to read certfile: #{file}") + [] + end + end +end diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index ad5bc9863..c420c8bc3 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -5,16 +5,11 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do alias Pleroma.User - require Logger - - import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1, fetch_user: 1] + import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1] @behaviour Pleroma.Web.Auth.Authenticator @base Pleroma.Web.Auth.PleromaAuthenticator - @connection_timeout 10_000 - @search_timeout 10_000 - defdelegate get_registration(conn), to: @base defdelegate create_from_registration(conn, registration), to: @base defdelegate handle_error(conn, error), to: @base @@ -24,7 +19,7 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do def get_user(%Plug.Conn{} = conn) do with {:ldap, true} <- {:ldap, Pleroma.Config.get([:ldap, :enabled])}, {:ok, {name, password}} <- fetch_credentials(conn), - %User{} = user <- ldap_user(name, password) do + %User{} = user <- GenServer.call(Pleroma.LDAP, {:bind_user, name, password}) do {:ok, user} else {:ldap, _} -> @@ -34,142 +29,4 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do error end end - - defp ldap_user(name, password) do - ldap = Pleroma.Config.get(:ldap, []) - host = Keyword.get(ldap, :host, "localhost") - port = Keyword.get(ldap, :port, 389) - ssl = Keyword.get(ldap, :ssl, false) - tls = Keyword.get(ldap, :tls, false) - cacertfile = Keyword.get(ldap, :cacertfile) || CAStore.file_path() - - default_secure_opts = [ - verify: :verify_peer, - cacerts: decode_certfile(cacertfile), - customize_hostname_check: [ - fqdn_fun: fn _ -> to_charlist(host) end - ] - ] - - sslopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :sslopts, [])) - tlsopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :tlsopts, [])) - - # :sslopts can only be included in :eldap.open/2 when {ssl: true} - # or the connection will fail - options = - if ssl do - [{:port, port}, {:ssl, ssl}, {:sslopts, sslopts}, {:timeout, @connection_timeout}] - else - [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}] - end - - case :eldap.open([to_charlist(host)], options) do - {:ok, connection} -> - try do - cond do - ssl -> - :application.ensure_all_started(:ssl) - - tls -> - case :eldap.start_tls( - connection, - tlsopts, - @connection_timeout - ) do - :ok -> - :ok - - error -> - Logger.error("Could not start TLS: #{inspect(error)}") - :eldap.close(connection) - end - - true -> - :ok - end - - bind_user(connection, ldap, name, password) - after - :eldap.close(connection) - end - - {:error, error} -> - Logger.error("Could not open LDAP connection: #{inspect(error)}") - {:error, {:ldap_connection_error, error}} - end - end - - defp bind_user(connection, ldap, name, password) do - uid = Keyword.get(ldap, :uid, "cn") - base = Keyword.get(ldap, :base) - - case :eldap.simple_bind(connection, "#{uid}=#{name},#{base}", password) do - :ok -> - case fetch_user(name) do - %User{} = user -> - user - - _ -> - register_user(connection, base, uid, name) - end - - error -> - Logger.error("Could not bind LDAP user #{name}: #{inspect(error)}") - {:error, {:ldap_bind_error, error}} - end - end - - 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))}, - {:scope, :eldap.wholeSubtree()}, - {:timeout, @search_timeout} - ]) do - # The :eldap_search_result record structure changed in OTP 24.3 and added a controls field - # https://github.com/erlang/otp/pull/5538 - {:ok, {:eldap_search_result, [{:eldap_entry, _object, attributes}], _referrals}} -> - try_register(name, attributes) - - {:ok, {:eldap_search_result, [{:eldap_entry, _object, attributes}], _referrals, _controls}} -> - try_register(name, attributes) - - error -> - Logger.error("Couldn't register user because LDAP search failed: #{inspect(error)}") - {:error, {:ldap_search_error, error}} - end - end - - defp try_register(name, attributes) do - params = %{ - name: name, - nickname: name, - password: nil - } - - params = - case List.keyfind(attributes, ~c"mail", 0) do - {_, [mail]} -> Map.put_new(params, :email, :erlang.list_to_binary(mail)) - _ -> params - end - - changeset = User.register_changeset_ldap(%User{}, params) - - case User.register(changeset) do - {:ok, user} -> user - error -> error - end - end - - defp decode_certfile(file) do - with {:ok, data} <- File.read(file) do - data - |> :public_key.pem_decode() - |> Enum.map(fn {_, b, _} -> b end) - else - _ -> - Logger.error("Unable to read certfile: #{file}") - [] - end - end end From ead287d623e83b8d9ffaa327b9edf96e046bfacd Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 16 Sep 2024 13:14:19 -0400 Subject: [PATCH 107/212] Credo --- lib/pleroma/ldap.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index 868932501..11544f0d9 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -51,7 +51,7 @@ defmodule Pleroma.LDAP do {:noreply, state} end - defp do_handle_connect() do + defp do_handle_connect do state = case connect() do {:ok, connection} -> @@ -86,7 +86,7 @@ defmodule Pleroma.LDAP do :ok end - defp connect() do + defp connect do ldap = Config.get(:ldap, []) host = Keyword.get(ldap, :host, "localhost") port = Keyword.get(ldap, :port, 389) From 7c04098dde0681f7ad299782bc09eaa9bc3a6bad Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 16 Sep 2024 16:15:53 -0400 Subject: [PATCH 108/212] Catchall for when LDAP is not enabled --- lib/pleroma/ldap.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index 11544f0d9..ac819613e 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -34,6 +34,9 @@ defmodule Pleroma.LDAP do ) {:ok, state} + + _ -> + {:ok, state} end end From 44b836c94c1059551fbc7564770001311d2d1e6a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 16 Sep 2024 16:24:27 -0400 Subject: [PATCH 109/212] Fix tests We do not need to mock and verify connections are closed as the new Pleroma.LDAP GenServer will handle managing the connection lifetime --- .../web/o_auth/ldap_authorization_test.exs | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/test/pleroma/web/o_auth/ldap_authorization_test.exs b/test/pleroma/web/o_auth/ldap_authorization_test.exs index 07ce2eed8..35b947fd0 100644 --- a/test/pleroma/web/o_auth/ldap_authorization_test.exs +++ b/test/pleroma/web/o_auth/ldap_authorization_test.exs @@ -28,11 +28,7 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do {:eldap, [], [ open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:ok, self()} end, - simple_bind: fn _connection, _dn, ^password -> :ok end, - close: fn _connection -> - send(self(), :close_connection) - :ok - end + simple_bind: fn _connection, _dn, ^password -> :ok end ]} ] do conn = @@ -50,7 +46,6 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do token = Repo.get_by(Token, token: token) assert token.user_id == user.id - assert_received :close_connection end end @@ -72,10 +67,6 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do wholeSubtree: fn -> :ok end, search: fn _connection, _options -> {:ok, {:eldap_search_result, [{:eldap_entry, ~c"", []}], []}} - end, - close: fn _connection -> - send(self(), :close_connection) - :ok end ]} ] do @@ -94,7 +85,6 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do token = Repo.get_by(Token, token: token) |> Repo.preload(:user) assert token.user.nickname == user.nickname - assert_received :close_connection end end @@ -111,11 +101,7 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do {:eldap, [], [ open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:ok, self()} end, - simple_bind: fn _connection, _dn, ^password -> {:error, :invalidCredentials} end, - close: fn _connection -> - send(self(), :close_connection) - :ok - end + simple_bind: fn _connection, _dn, ^password -> {:error, :invalidCredentials} end ]} ] do conn = @@ -129,7 +115,6 @@ defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do }) assert %{"error" => "Invalid credentials"} = json_response(conn, 400) - assert_received :close_connection end end end From d82abf925ddbe8b98ba8191713115db50c38a0c0 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 16 Sep 2024 16:25:44 -0400 Subject: [PATCH 110/212] Ensure :cacertfile is configurable in ConfigDB --- config/description.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/description.exs b/config/description.exs index 15faecb38..ade47b7e0 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2297,6 +2297,12 @@ config :pleroma, :config_description, [ description: "LDAP attribute name to authenticate the user, e.g. when \"cn\", the filter will be \"cn=username,base\"", suggestions: ["cn"] + }, + %{ + key: :cacertfile, + label: "CACertfile", + type: :string, + description: "Path to CA certificate file" } ] }, From 65a7b387c35b4913b6109692a84bae80af8b9a96 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 16 Sep 2024 16:28:37 -0400 Subject: [PATCH 111/212] Require a reboot if LDAP configuration changes --- lib/pleroma/config/transfer_task.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/config/transfer_task.ex b/lib/pleroma/config/transfer_task.ex index ffc95f144..140dd7711 100644 --- a/lib/pleroma/config/transfer_task.ex +++ b/lib/pleroma/config/transfer_task.ex @@ -22,7 +22,8 @@ defmodule Pleroma.Config.TransferTask do {:pleroma, :markup}, {:pleroma, :streamer}, {:pleroma, :pools}, - {:pleroma, :connections_pool} + {:pleroma, :connections_pool}, + {:pleroma, :ldap} ] defp reboot_time_subkeys, From 123093a1868b25f101dfc4b02895c22a0daf5733 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 17 Sep 2024 13:07:26 -0400 Subject: [PATCH 112/212] Ensure :ssl is started before we attempt to make the LDAP connection --- lib/pleroma/ldap.ex | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index ac819613e..042a4daa2 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -97,6 +97,8 @@ defmodule Pleroma.LDAP do tls = Keyword.get(ldap, :tls, false) cacertfile = Keyword.get(ldap, :cacertfile) || CAStore.file_path() + if ssl, do: Application.ensure_all_started(:ssl) + default_secure_opts = [ verify: :verify_peer, cacerts: decode_certfile(cacertfile), @@ -123,10 +125,6 @@ defmodule Pleroma.LDAP do {:ok, connection} -> try do cond do - ssl -> - :application.ensure_all_started(:ssl) - {:ok, connection} - tls -> case :eldap.start_tls( connection, From d0ee899ab94788e37e6ac3c43342017d1b27903a Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 17 Sep 2024 13:14:09 -0400 Subject: [PATCH 113/212] Only close connection if it is not nil --- lib/pleroma/ldap.ex | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index 042a4daa2..3df20fe09 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -84,7 +84,11 @@ defmodule Pleroma.LDAP do @impl true def terminate(_, state) do - :eldap.close(state[:connection]) + connection = Keyword.get(state, :connection) + + if not is_nil(connection) do + :eldap.close(connection) + end :ok end From 164ffbcab822eda4c28f912082b6a7a3ec64a7e5 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 17 Sep 2024 13:17:40 -0400 Subject: [PATCH 114/212] Fix return value when not doing STARTTLS --- lib/pleroma/ldap.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index 3df20fe09..6be2188f4 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -144,7 +144,7 @@ defmodule Pleroma.LDAP do end true -> - {:ok, :connection} + {:ok, connection} end after :ok From a1972d57e30f41e5173d61c0d0936685738c560d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 17 Sep 2024 13:19:54 -0400 Subject: [PATCH 115/212] Link the eldap connection process Ensure if LDAP GenServer crashes it gets cleaned up, and we should crash and restart if somehow the eldap connection process crashes unexpectedly as we can't seem to receive any DOWN messages from it, etc. --- lib/pleroma/ldap.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index 6be2188f4..0723cd094 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -59,6 +59,7 @@ defmodule Pleroma.LDAP do case connect() do {:ok, connection} -> :eldap.controlling_process(connection, self()) + Process.link(connection) [connection: connection] _ -> From 14a9663f1abe49b8f4f4f719fa2f4db3a5dd81b7 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 17 Sep 2024 13:28:42 -0400 Subject: [PATCH 116/212] Remove cacertfile as child of SSL and TLS options We need to pass the cacerts (list of charlist encoded certs) not cacertfile, so our new cacertfile setting handles this for us. --- config/description.exs | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/config/description.exs b/config/description.exs index ade47b7e0..5062842f0 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2241,14 +2241,8 @@ config :pleroma, :config_description, [ label: "SSL options", type: :keyword, description: "Additional SSL options", - suggestions: [cacertfile: "path/to/file/with/PEM/cacerts", verify: :verify_peer], + suggestions: [verify: :verify_peer], children: [ - %{ - key: :cacertfile, - type: :string, - description: "Path to file with PEM encoded cacerts", - suggestions: ["path/to/file/with/PEM/cacerts"] - }, %{ key: :verify, type: :atom, @@ -2268,14 +2262,8 @@ config :pleroma, :config_description, [ label: "TLS options", type: :keyword, description: "Additional TLS options", - suggestions: [cacertfile: "path/to/file/with/PEM/cacerts", verify: :verify_peer], + suggestions: [verify: :verify_peer], children: [ - %{ - key: :cacertfile, - type: :string, - description: "Path to file with PEM encoded cacerts", - suggestions: ["path/to/file/with/PEM/cacerts"] - }, %{ key: :verify, type: :atom, From 363b462c54c454e847072869db09f8f4d5da4426 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 17 Sep 2024 13:36:46 -0400 Subject: [PATCH 117/212] Make the email attribute configurable While here, fix the System.get_env usage to use the normal fallback value method and improve the UID label description --- config/config.exs | 11 ++++++----- config/description.exs | 9 ++++++++- lib/pleroma/ldap.ex | 4 +++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/config/config.exs b/config/config.exs index f53a083d0..47ddfac5a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -612,16 +612,17 @@ config :pleroma, Pleroma.Formatter, config :pleroma, :ldap, enabled: System.get_env("LDAP_ENABLED") == "true", - host: System.get_env("LDAP_HOST") || "localhost", - port: String.to_integer(System.get_env("LDAP_PORT") || "389"), + host: System.get_env("LDAP_HOST", "localhost"), + port: String.to_integer(System.get_env("LDAP_PORT", "389")), ssl: System.get_env("LDAP_SSL") == "true", sslopts: [], tls: System.get_env("LDAP_TLS") == "true", tlsopts: [], - base: System.get_env("LDAP_BASE") || "dc=example,dc=com", - uid: System.get_env("LDAP_UID") || "cn", + base: System.get_env("LDAP_BASE", "dc=example,dc=com"), + uid: System.get_env("LDAP_UID", "cn"), # defaults to CAStore's Mozilla roots - cacertfile: nil + cacertfile: System.get_env("LDAP_CACERTFILE", nil), + mail: System.get_env("LDAP_MAIL", "mail") oauth_consumer_strategies = System.get_env("OAUTH_CONSUMER_STRATEGIES") diff --git a/config/description.exs b/config/description.exs index 5062842f0..e85ec0ff8 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2280,7 +2280,7 @@ config :pleroma, :config_description, [ }, %{ key: :uid, - label: "UID", + label: "UID Attribute", type: :string, description: "LDAP attribute name to authenticate the user, e.g. when \"cn\", the filter will be \"cn=username,base\"", @@ -2291,6 +2291,13 @@ config :pleroma, :config_description, [ label: "CACertfile", type: :string, description: "Path to CA certificate file" + }, + %{ + key: :mail, + label: "Mail Attribute", + type: :string, + description: "LDAP attribute name to use as the email address when automatically registering the user on first login", + suggestions: ["mail"] } ] }, diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index 0723cd094..8e9c591b2 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -205,6 +205,8 @@ defmodule Pleroma.LDAP do end defp try_register(name, attributes) do + mail_attribute = Config.get([:ldap, :mail]) + params = %{ name: name, nickname: name, @@ -212,7 +214,7 @@ defmodule Pleroma.LDAP do } params = - case List.keyfind(attributes, ~c"mail", 0) do + case List.keyfind(attributes, to_charlist(mail_attribute), 0) do {_, [mail]} -> Map.put_new(params, :email, :erlang.list_to_binary(mail)) _ -> params end From 21bf229731f27426564140650397e51ba4bb4b93 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 17 Sep 2024 13:43:21 -0400 Subject: [PATCH 118/212] Reduce LDAP timeouts 10 seconds is way too long for any login attempt or search result. LDAP should always be fast. --- lib/pleroma/ldap.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index 8e9c591b2..33c9cff29 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -8,8 +8,8 @@ defmodule Pleroma.LDAP do import Pleroma.Web.Auth.Helpers, only: [fetch_user: 1] - @connection_timeout 10_000 - @search_timeout 10_000 + @connection_timeout 2_000 + @search_timeout 2_000 def start_link(_) do GenServer.start_link(__MODULE__, [], name: __MODULE__) From 1d123832da6a2b8c67f34006b4ea05e0be86e366 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 17 Sep 2024 13:46:49 -0400 Subject: [PATCH 119/212] Formatting --- config/description.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/description.exs b/config/description.exs index e85ec0ff8..47f4771eb 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2296,7 +2296,8 @@ config :pleroma, :config_description, [ key: :mail, label: "Mail Attribute", type: :string, - description: "LDAP attribute name to use as the email address when automatically registering the user on first login", + description: + "LDAP attribute name to use as the email address when automatically registering the user on first login", suggestions: ["mail"] } ] From ea63533cf28be8218a27806b1f6430f0c1ca4a01 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 17 Sep 2024 13:46:56 -0400 Subject: [PATCH 120/212] Change :connection to :handle to match upstream nomenclature --- lib/pleroma/ldap.ex | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index 33c9cff29..b357b371a 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -47,7 +47,7 @@ defmodule Pleroma.LDAP do def handle_info(:connect, _state), do: do_handle_connect() def handle_info({:bind_after_reconnect, name, password, from}, state) do - result = bind_user(state[:connection], name, password) + result = bind_user(state[:handle], name, password) GenServer.reply(from, result) @@ -57,10 +57,10 @@ defmodule Pleroma.LDAP do defp do_handle_connect do state = case connect() do - {:ok, connection} -> - :eldap.controlling_process(connection, self()) - Process.link(connection) - [connection: connection] + {:ok, handle} -> + :eldap.controlling_process(handle, self()) + Process.link(handle) + [handle: handle] _ -> Logger.error("Failed to connect to LDAP. Retrying in 5000ms") @@ -73,7 +73,7 @@ defmodule Pleroma.LDAP do @impl true def handle_call({:bind_user, name, password}, from, state) do - case bind_user(state[:connection], name, password) do + case bind_user(state[:handle], name, password) do :needs_reconnect -> Process.send(self(), {:bind_after_reconnect, name, password, from}, []) {:noreply, state, {:continue, :connect}} @@ -85,10 +85,10 @@ defmodule Pleroma.LDAP do @impl true def terminate(_, state) do - connection = Keyword.get(state, :connection) + handle = Keyword.get(state, :handle) - if not is_nil(connection) do - :eldap.close(connection) + if not is_nil(handle) do + :eldap.close(handle) end :ok @@ -127,25 +127,25 @@ defmodule Pleroma.LDAP do end case :eldap.open([to_charlist(host)], options) do - {:ok, connection} -> + {:ok, handle} -> try do cond do tls -> case :eldap.start_tls( - connection, + handle, tlsopts, @connection_timeout ) do :ok -> - {:ok, connection} + {:ok, handle} error -> Logger.error("Could not start TLS: #{inspect(error)}") - :eldap.close(connection) + :eldap.close(handle) end true -> - {:ok, connection} + {:ok, handle} end after :ok @@ -157,24 +157,24 @@ defmodule Pleroma.LDAP do end end - defp bind_user(connection, name, password) do + defp bind_user(handle, name, password) do uid = Config.get([:ldap, :uid], "cn") base = Config.get([:ldap, :base]) - case :eldap.simple_bind(connection, "#{uid}=#{name},#{base}", password) do + case :eldap.simple_bind(handle, "#{uid}=#{name},#{base}", password) do :ok -> case fetch_user(name) do %User{} = user -> user _ -> - register_user(connection, base, uid, name) + register_user(handle, base, uid, name) end # eldap does not inform us of socket closure # until it is used {:error, {:gen_tcp_error, :closed}} -> - :eldap.close(connection) + :eldap.close(handle) :needs_reconnect error -> @@ -183,8 +183,8 @@ defmodule Pleroma.LDAP do end end - defp register_user(connection, base, uid, name) do - case :eldap.search(connection, [ + defp register_user(handle, base, uid, name) do + case :eldap.search(handle, [ {:base, to_charlist(base)}, {:filter, :eldap.equalityMatch(to_charlist(uid), to_charlist(name))}, {:scope, :eldap.wholeSubtree()}, From 2b482e34ebf5aee49350d8198e6fade8820b3834 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 17 Sep 2024 13:54:57 -0400 Subject: [PATCH 121/212] Improve matching on bind errors --- lib/pleroma/ldap.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index b357b371a..cd84dee02 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -177,9 +177,9 @@ defmodule Pleroma.LDAP do :eldap.close(handle) :needs_reconnect - error -> + {:error, error} = e -> Logger.error("Could not bind LDAP user #{name}: #{inspect(error)}") - {:error, {:ldap_bind_error, error}} + e end end From 35ddb1d2c8a53dcb54178522811242ef40a63211 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 17 Sep 2024 13:57:10 -0400 Subject: [PATCH 122/212] LDAP genserver changelog --- changelog.d/ldap-refactor.change | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/ldap-refactor.change diff --git a/changelog.d/ldap-refactor.change b/changelog.d/ldap-refactor.change new file mode 100644 index 000000000..1510eea6a --- /dev/null +++ b/changelog.d/ldap-refactor.change @@ -0,0 +1 @@ +LDAP authentication has been refactored to operate as a GenServer process which will maintain an active connection to the LDAP server. From 1de5208a9e90485be38ac0d00088f18c5b36390a Mon Sep 17 00:00:00 2001 From: Mint Date: Tue, 17 Sep 2024 21:48:37 +0300 Subject: [PATCH 123/212] Cheatsheet: add Mua mail adapter config --- docs/configuration/cheatsheet.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/configuration/cheatsheet.md b/docs/configuration/cheatsheet.md index 0b4e53b6f..ba41fe84d 100644 --- a/docs/configuration/cheatsheet.md +++ b/docs/configuration/cheatsheet.md @@ -742,6 +742,21 @@ config :pleroma, Pleroma.Emails.Mailer, auth: :always ``` +An example for Mua adapter: + +```elixir +config :pleroma, Pleroma.Emails.Mailer, + enabled: true, + adapter: Swoosh.Adapters.Mua, + relay: "mail.example.com", + port: 465, + auth: [ + username: "YOUR_USERNAME@domain.tld", + password: "YOUR_SMTP_PASSWORD" + ], + protocol: :ssl +``` + ### :email_notifications Email notifications settings. From 73204c1bca740dbca5c780891fc720ac728c11a6 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 18 Sep 2024 11:16:16 -0400 Subject: [PATCH 124/212] LDAP: fix compile warning Sometimes the compile will emit the following warning, so we'll just avoid it by making it call a function in the LDAP module which will never have this problem. warning: :GenServer.call/2 is undefined (module :GenServer is not available or is yet to be defined) --- changelog.d/ldap-warning.skip | 0 lib/pleroma/ldap.ex | 4 ++++ lib/pleroma/web/auth/ldap_authenticator.ex | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 changelog.d/ldap-warning.skip diff --git a/changelog.d/ldap-warning.skip b/changelog.d/ldap-warning.skip new file mode 100644 index 000000000..e69de29bb diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index cd84dee02..46a2d0c17 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -94,6 +94,10 @@ defmodule Pleroma.LDAP do :ok end + def bind_user(name, password) do + GenServer.call(__MODULE__, {:bind_user, name, password}) + end + defp connect do ldap = Config.get(:ldap, []) host = Keyword.get(ldap, :host, "localhost") diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index c420c8bc3..7eb06183d 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.Auth.LDAPAuthenticator do + alias Pleroma.LDAP alias Pleroma.User import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1] @@ -19,7 +20,7 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do def get_user(%Plug.Conn{} = conn) do with {:ldap, true} <- {:ldap, Pleroma.Config.get([:ldap, :enabled])}, {:ok, {name, password}} <- fetch_credentials(conn), - %User{} = user <- GenServer.call(Pleroma.LDAP, {:bind_user, name, password}) do + %User{} = user <- LDAP.bind_user(name, password) do {:ok, user} else {:ldap, _} -> From ecd1b8393befe91175872af3db67a5c01f10eaf2 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 18 Sep 2024 12:07:52 -0400 Subject: [PATCH 125/212] Oban: update to 2.18.3 This release includes the fix which should prevent the scenario where Postgrex crashes can cause Oban to get into a state where it will stop processing jobs. --- changelog.d/oban-update.change | 1 + mix.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/oban-update.change diff --git a/changelog.d/oban-update.change b/changelog.d/oban-update.change new file mode 100644 index 000000000..48a54ed2d --- /dev/null +++ b/changelog.d/oban-update.change @@ -0,0 +1 @@ +Oban updated to 2.18.3 diff --git a/mix.lock b/mix.lock index 2cf44862b..421f99ec0 100644 --- a/mix.lock +++ b/mix.lock @@ -92,7 +92,7 @@ "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, "nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"}, "nodex": {:git, "https://git.pleroma.social/pleroma/nodex", "cb6730f943cfc6aad674c92161be23a8411f15d1", [ref: "cb6730f943cfc6aad674c92161be23a8411f15d1"]}, - "oban": {:hex, :oban, "2.18.2", "583e78965ee15263ac968e38c983bad169ae55eadaa8e1e39912562badff93ba", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9dd25fd35883a91ed995e9fe516e479344d3a8623dfe2b8c3fc8e5be0228ec3a"}, + "oban": {:hex, :oban, "2.18.3", "1608c04f8856c108555c379f2f56bc0759149d35fa9d3b825cb8a6769f8ae926", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "36ca6ca84ef6518f9c2c759ea88efd438a3c81d667ba23b02b062a0aa785475e"}, "oban_live_dashboard": {:hex, :oban_live_dashboard, "0.1.1", "8aa4ceaf381c818f7d5c8185cc59942b8ac82ef0cf559881aacf8d3f8ac7bdd3", [:mix], [{:oban, "~> 2.15", [hex: :oban, repo: "hexpm", optional: false]}, {:phoenix_live_dashboard, "~> 0.7", [hex: :phoenix_live_dashboard, repo: "hexpm", optional: false]}], "hexpm", "16dc4ce9c9a95aa2e655e35ed4e675652994a8def61731a18af85e230e1caa63"}, "octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"}, "open_api_spex": {:hex, :open_api_spex, "3.18.2", "8c855e83bfe8bf81603d919d6e892541eafece3720f34d1700b58024dadde247", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "aa3e6dcfc0ad6a02596b2172662da21c9dd848dac145ea9e603f54e3d81b8d2b"}, From f00545d85bd601734cdbbc28454f33541dbf530d Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 18 Sep 2024 13:14:17 -0400 Subject: [PATCH 126/212] Elixir 1.14 and Erlang/OTP 23 is now the minimum supported release --- .gitlab-ci.yml | 8 ++++---- changelog.d/elixir.change | 1 + docs/installation/debian_based_jp.md | 2 +- docs/installation/generic_dependencies.include | 4 ++-- mix.exs | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 changelog.d/elixir.change diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 76d1a4210..39947c75e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,8 +1,8 @@ -image: git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.13.4-otp-25 +image: git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.14.5-otp-25 variables: &global_variables # Only used for the release - ELIXIR_VER: 1.13.4 + ELIXIR_VER: 1.14.5 POSTGRES_DB: pleroma_test POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -71,7 +71,7 @@ check-changelog: tags: - amd64 -build-1.13.4-otp-25: +build-1.14.5-otp-25: extends: - .build_changes_policy - .using-ci-base @@ -119,7 +119,7 @@ benchmark: - mix ecto.migrate - mix pleroma.load_testing -unit-testing-1.13.4-otp-25: +unit-testing-1.14.5-otp-25: extends: - .build_changes_policy - .using-ci-base diff --git a/changelog.d/elixir.change b/changelog.d/elixir.change new file mode 100644 index 000000000..779c01562 --- /dev/null +++ b/changelog.d/elixir.change @@ -0,0 +1 @@ +Elixir 1.14 and Erlang/OTP 23 is now the minimum supported release diff --git a/docs/installation/debian_based_jp.md b/docs/installation/debian_based_jp.md index 5a0823a63..0817934ff 100644 --- a/docs/installation/debian_based_jp.md +++ b/docs/installation/debian_based_jp.md @@ -14,7 +14,7 @@ Note: This article is potentially outdated because at this time we may not have - PostgreSQL 11.0以上 (Ubuntu16.04では9.5しか提供されていないので,[](https://www.postgresql.org/download/linux/ubuntu/)こちらから新しいバージョンを入手してください) - `postgresql-contrib` 11.0以上 (同上) -- Elixir 1.13 以上 ([Debianのリポジトリからインストールしないこと!!! ここからインストールすること!](https://elixir-lang.org/install.html#unix-and-unix-like)。または [asdf](https://github.com/asdf-vm/asdf) をpleromaユーザーでインストールしてください) +- Elixir 1.14 以上 ([Debianのリポジトリからインストールしないこと!!! ここからインストールすること!](https://elixir-lang.org/install.html#unix-and-unix-like)。または [asdf](https://github.com/asdf-vm/asdf) をpleromaユーザーでインストールしてください) - `erlang-dev` - `erlang-nox` - `git` diff --git a/docs/installation/generic_dependencies.include b/docs/installation/generic_dependencies.include index bdb7f94d3..9f07f62c6 100644 --- a/docs/installation/generic_dependencies.include +++ b/docs/installation/generic_dependencies.include @@ -1,8 +1,8 @@ ## Required dependencies * PostgreSQL >=11.0 -* Elixir >=1.13.0 <1.17 -* Erlang OTP >=22.2.0 (supported: <27) +* Elixir >=1.14.0 <1.17 +* Erlang OTP >=23.0.0 (supported: <27) * git * file / libmagic * gcc or clang diff --git a/mix.exs b/mix.exs index ceae5c26d..89ec5e831 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule Pleroma.Mixfile do [ app: :pleroma, version: version("2.7.0"), - elixir: "~> 1.13", + elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), compilers: Mix.compilers(), elixirc_options: [warnings_as_errors: warnings_as_errors(), prune_code_paths: false], From 7e303600fb2914ab66fe54a8022ddc65ff93edf5 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 18 Sep 2024 17:15:55 +0000 Subject: [PATCH 127/212] Remove old elixir 1.12 build image generation script --- ci/elixir-1.12/Dockerfile | 8 -------- ci/elixir-1.12/build_and_push.sh | 1 - 2 files changed, 9 deletions(-) delete mode 100644 ci/elixir-1.12/Dockerfile delete mode 100755 ci/elixir-1.12/build_and_push.sh diff --git a/ci/elixir-1.12/Dockerfile b/ci/elixir-1.12/Dockerfile deleted file mode 100644 index a2b566873..000000000 --- a/ci/elixir-1.12/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM elixir:1.12.3 - -# Single RUN statement, otherwise intermediate images are created -# https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#run -RUN apt-get update &&\ - apt-get install -y libmagic-dev cmake libimage-exiftool-perl ffmpeg &&\ - mix local.hex --force &&\ - mix local.rebar --force diff --git a/ci/elixir-1.12/build_and_push.sh b/ci/elixir-1.12/build_and_push.sh deleted file mode 100755 index 508262ed8..000000000 --- a/ci/elixir-1.12/build_and_push.sh +++ /dev/null @@ -1 +0,0 @@ -docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.12 --push . From 1bd28e7d592b429c5eee072db8d1f2ae77d76e29 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 18 Sep 2024 17:28:48 +0000 Subject: [PATCH 128/212] CI script to build and publish an image for Elixir 1.14 --- ci/{elixir-1.13.4-otp-25 => elixir-1.14.5-otp-25}/Dockerfile | 2 +- .../build_and_push.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename ci/{elixir-1.13.4-otp-25 => elixir-1.14.5-otp-25}/Dockerfile (91%) rename ci/{elixir-1.13.4-otp-25 => elixir-1.14.5-otp-25}/build_and_push.sh (52%) diff --git a/ci/elixir-1.13.4-otp-25/Dockerfile b/ci/elixir-1.14.5-otp-25/Dockerfile similarity index 91% rename from ci/elixir-1.13.4-otp-25/Dockerfile rename to ci/elixir-1.14.5-otp-25/Dockerfile index 25a1639e8..3a35c84c3 100644 --- a/ci/elixir-1.13.4-otp-25/Dockerfile +++ b/ci/elixir-1.14.5-otp-25/Dockerfile @@ -1,4 +1,4 @@ -FROM elixir:1.13.4-otp-25 +FROM elixir:1.14.5-otp-25 # Single RUN statement, otherwise intermediate images are created # https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#run diff --git a/ci/elixir-1.13.4-otp-25/build_and_push.sh b/ci/elixir-1.14.5-otp-25/build_and_push.sh similarity index 52% rename from ci/elixir-1.13.4-otp-25/build_and_push.sh rename to ci/elixir-1.14.5-otp-25/build_and_push.sh index b8ca1d24d..912c47d0c 100755 --- a/ci/elixir-1.13.4-otp-25/build_and_push.sh +++ b/ci/elixir-1.14.5-otp-25/build_and_push.sh @@ -1 +1 @@ -docker buildx build --platform linux/amd64,linux/arm64 -t git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.13.4-otp-25 --push . +docker buildx build --platform linux/amd64,linux/arm64 -t git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.14.5-otp-25 --push . From 23e5eed4e0e61ea65bd895bee7d8a137fccf3307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 19 Sep 2024 10:57:50 +0200 Subject: [PATCH 129/212] Include session scopes in TokenView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- changelog.d/token-view-scopes.add | 1 + lib/pleroma/web/twitter_api/views/token_view.ex | 3 ++- test/pleroma/web/twitter_api/controller_test.exs | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 changelog.d/token-view-scopes.add diff --git a/changelog.d/token-view-scopes.add b/changelog.d/token-view-scopes.add new file mode 100644 index 000000000..e24fa38e6 --- /dev/null +++ b/changelog.d/token-view-scopes.add @@ -0,0 +1 @@ +Include session scopes in TokenView \ No newline at end of file diff --git a/lib/pleroma/web/twitter_api/views/token_view.ex b/lib/pleroma/web/twitter_api/views/token_view.ex index 2e492c13f..36776ce3b 100644 --- a/lib/pleroma/web/twitter_api/views/token_view.ex +++ b/lib/pleroma/web/twitter_api/views/token_view.ex @@ -15,7 +15,8 @@ defmodule Pleroma.Web.TwitterAPI.TokenView do %{ id: token_entry.id, valid_until: token_entry.valid_until, - app_name: token_entry.app.client_name + app_name: token_entry.app.client_name, + scopes: token_entry.scopes } end end diff --git a/test/pleroma/web/twitter_api/controller_test.exs b/test/pleroma/web/twitter_api/controller_test.exs index 495d371d2..0019b51af 100644 --- a/test/pleroma/web/twitter_api/controller_test.exs +++ b/test/pleroma/web/twitter_api/controller_test.exs @@ -69,7 +69,7 @@ defmodule Pleroma.Web.TwitterAPI.ControllerTest do |> hd() |> Map.keys() - assert keys -- ["id", "app_name", "valid_until"] == [] + assert keys -- ["id", "app_name", "valid_until", "scopes"] == [] end test "revoke token", %{token: token} do From 03e14e759db47633cce320285d93d8c1f3bde65c Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 24 Mar 2023 09:08:39 +0100 Subject: [PATCH 130/212] MRF: Add filtering against AP id --- changelog.d/mrf-id_filter.add | 1 + lib/pleroma/web/activity_pub/mrf.ex | 8 ++++++++ lib/pleroma/web/activity_pub/mrf/policy.ex | 3 ++- 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 changelog.d/mrf-id_filter.add diff --git a/changelog.d/mrf-id_filter.add b/changelog.d/mrf-id_filter.add new file mode 100644 index 000000000..f556f9bc4 --- /dev/null +++ b/changelog.d/mrf-id_filter.add @@ -0,0 +1 @@ +Add `id_filter` to MRF to filter URLs and their domain prior to fetching \ No newline at end of file diff --git a/lib/pleroma/web/activity_pub/mrf.ex b/lib/pleroma/web/activity_pub/mrf.ex index bc418d908..51ab476b7 100644 --- a/lib/pleroma/web/activity_pub/mrf.ex +++ b/lib/pleroma/web/activity_pub/mrf.ex @@ -108,6 +108,14 @@ defmodule Pleroma.Web.ActivityPub.MRF do def filter(%{} = object), do: get_policies() |> filter(object) + def id_filter(policies, id) when is_binary(id) do + policies + |> Enum.filter(&function_exported?(&1, :id_filter, 1)) + |> Enum.all?(& &1.id_filter(id)) + end + + def id_filter(id) when is_binary(id), do: get_policies() |> id_filter(id) + @impl true def pipeline_filter(%{} = message, meta) do object = meta[:object_data] diff --git a/lib/pleroma/web/activity_pub/mrf/policy.ex b/lib/pleroma/web/activity_pub/mrf/policy.ex index 54ca4b735..08bcac08a 100644 --- a/lib/pleroma/web/activity_pub/mrf/policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/policy.ex @@ -4,6 +4,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.Policy do @callback filter(Pleroma.Activity.t()) :: {:ok | :reject, Pleroma.Activity.t()} + @callback id_filter(String.t()) :: boolean() @callback describe() :: {:ok | :error, map()} @callback config_description() :: %{ optional(:children) => [map()], @@ -13,5 +14,5 @@ defmodule Pleroma.Web.ActivityPub.MRF.Policy do description: String.t() } @callback history_awareness() :: :auto | :manual - @optional_callbacks config_description: 0, history_awareness: 0 + @optional_callbacks config_description: 0, history_awareness: 0, id_filter: 1 end From 3dd6f6585985a085c1c2f2243501323864dcac2d Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 24 Mar 2023 09:09:41 +0100 Subject: [PATCH 131/212] Object.Fetcher: Hook to MRF.id_filter --- lib/pleroma/object/fetcher.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 69a5f3268..c85a8b09f 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -145,6 +145,7 @@ defmodule Pleroma.Object.Fetcher do Logger.debug("Fetching object #{id} via AP") with {:scheme, true} <- {:scheme, String.starts_with?(id, "http")}, + {_, true} <- {:mrf, MRF.id_filter(id)}, {:ok, body} <- get_object(id), {:ok, data} <- safe_json_decode(body), :ok <- Containment.contain_origin_from_id(id, data) do @@ -160,6 +161,9 @@ defmodule Pleroma.Object.Fetcher do {:error, e} -> {:error, e} + {:mrf, false} -> + {:error, {:reject, "Filtered by id"}} + e -> {:error, e} end From 30063c5914d229753f3bacab98c38736f2a447e6 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 24 Mar 2023 09:09:58 +0100 Subject: [PATCH 132/212] MRF.DropPolicy: Add id_filter/1 --- lib/pleroma/web/activity_pub/mrf/drop_policy.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex index e4fcc9935..cf07db7f3 100644 --- a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex @@ -13,6 +13,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.DropPolicy do {:reject, activity} end + @impl true + def id_filter(id) do + Logger.debug("REJECTING #{id}") + false + end + @impl true def describe, do: {:ok, %{}} end From 0fa13c55357ca83ae00b39626a0fa4be3a936640 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Fri, 24 Mar 2023 09:16:25 +0100 Subject: [PATCH 133/212] MRF.SimplePolicy: Add id_filter/1 --- lib/pleroma/web/activity_pub/mrf/simple_policy.ex | 12 ++++++++++++ .../web/activity_pub/mrf/simple_policy_test.exs | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex index ae7f18bfe..a97e8db7b 100644 --- a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex @@ -191,6 +191,18 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do |> MRF.instance_list_from_tuples() end + @impl true + def id_filter(id) do + host_info = URI.parse(id) + + with {:ok, _} <- check_accept(host_info, %{}), + {:ok, _} <- check_reject(host_info, %{}) do + true + else + _ -> false + end + end + @impl true def filter(%{"type" => "Delete", "actor" => actor} = activity) do %{host: actor_host} = URI.parse(actor) diff --git a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs index 1a51b7d30..f49a7b8ff 100644 --- a/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/simple_policy_test.exs @@ -252,6 +252,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do remote_message = build_remote_message() assert SimplePolicy.filter(remote_message) == {:ok, remote_message} + assert SimplePolicy.id_filter(remote_message["actor"]) end test "activity has a matching host" do @@ -260,6 +261,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do remote_message = build_remote_message() assert {:reject, _} = SimplePolicy.filter(remote_message) + refute SimplePolicy.id_filter(remote_message["actor"]) end test "activity matches with wildcard domain" do @@ -268,6 +270,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do remote_message = build_remote_message() assert {:reject, _} = SimplePolicy.filter(remote_message) + refute SimplePolicy.id_filter(remote_message["actor"]) end test "actor has a matching host" do @@ -276,6 +279,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do remote_user = build_remote_user() assert {:reject, _} = SimplePolicy.filter(remote_user) + refute SimplePolicy.id_filter(remote_user["id"]) end test "reject Announce when object would be rejected" do @@ -288,6 +292,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do } assert {:reject, _} = SimplePolicy.filter(announce) + # Note: Non-Applicable for id_filter/1 end test "reject by URI object" do @@ -300,6 +305,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do } assert {:reject, _} = SimplePolicy.filter(announce) + # Note: Non-Applicable for id_filter/1 end end @@ -370,6 +376,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do assert SimplePolicy.filter(local_message) == {:ok, local_message} assert SimplePolicy.filter(remote_message) == {:ok, remote_message} + assert SimplePolicy.id_filter(local_message["actor"]) + assert SimplePolicy.id_filter(remote_message["actor"]) end test "is not empty but activity doesn't have a matching host" do @@ -380,6 +388,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do assert SimplePolicy.filter(local_message) == {:ok, local_message} assert {:reject, _} = SimplePolicy.filter(remote_message) + assert SimplePolicy.id_filter(local_message["actor"]) + refute SimplePolicy.id_filter(remote_message["actor"]) end test "activity has a matching host" do @@ -390,6 +400,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do assert SimplePolicy.filter(local_message) == {:ok, local_message} assert SimplePolicy.filter(remote_message) == {:ok, remote_message} + assert SimplePolicy.id_filter(local_message["actor"]) + assert SimplePolicy.id_filter(remote_message["actor"]) end test "activity matches with wildcard domain" do @@ -400,6 +412,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do assert SimplePolicy.filter(local_message) == {:ok, local_message} assert SimplePolicy.filter(remote_message) == {:ok, remote_message} + assert SimplePolicy.id_filter(local_message["actor"]) + assert SimplePolicy.id_filter(remote_message["actor"]) end test "actor has a matching host" do @@ -408,6 +422,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicyTest do remote_user = build_remote_user() assert SimplePolicy.filter(remote_user) == {:ok, remote_user} + assert SimplePolicy.id_filter(remote_user["id"]) end end From a1e3fb506b309a529f0ce8ef231d853e7866be21 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sat, 21 Sep 2024 15:39:02 +0200 Subject: [PATCH 134/212] Dockerfile: Elixir 1.14 --- Dockerfile | 7 ++++--- changelog.d/elixir-1.14-docker.skip | 0 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changelog.d/elixir-1.14-docker.skip diff --git a/Dockerfile b/Dockerfile index 72461305c..fff58154e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,8 @@ +# https://hub.docker.com/r/hexpm/elixir/tags ARG ELIXIR_IMG=hexpm/elixir -ARG ELIXIR_VER=1.13.4 -ARG ERLANG_VER=24.3.4.15 -ARG ALPINE_VER=3.17.5 +ARG ELIXIR_VER=1.14.5 +ARG ERLANG_VER=25.3.2.14 +ARG ALPINE_VER=3.17.9 FROM ${ELIXIR_IMG}:${ELIXIR_VER}-erlang-${ERLANG_VER}-alpine-${ALPINE_VER} as build diff --git a/changelog.d/elixir-1.14-docker.skip b/changelog.d/elixir-1.14-docker.skip new file mode 100644 index 000000000..e69de29bb From 7dd3a4d86defff9c0960f7e39481215603ac85b9 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 24 Sep 2024 05:54:25 +0200 Subject: [PATCH 135/212] push: make vapid_config fallback to empty array 2024-09-24T03:53:27.770757+00:00 NightmareMoon pleroma: path=/notice/AmJcSqyeyij4W70K36 [error] Preloading for /notice/AmJcSqyeyij4W70K36 failed. ** (FunctionClauseError) no function clause matching in Keyword.get/3 (elixir 1.15.8) lib/keyword.ex:388: Keyword.get(nil, :public_key, nil) (pleroma 2.7.0-3067-g9b76dbd4-dev-lanodan2) lib/pleroma/web/mastodon_api/views/instance_view.ex:262: Pleroma.Web.MastodonAPI.InstanceView.pleroma_configuration/1 (pleroma 2.7.0-3067-g9b76dbd4-dev-lanodan2) lib/pleroma/web/mastodon_api/views/instance_view.ex:45: Pleroma.Web.MastodonAPI.InstanceView.render/2 (pleroma 2.7.0-3067-g9b76dbd4-dev-lanodan2) lib/pleroma/web/preload/providers/instance.ex:28: Pleroma.Web.Preload.Providers.Instance.build_info_tag/1 (pleroma 2.7.0-3067-g9b76dbd4-dev-lanodan2) lib/pleroma/web/preload/providers/instance.ex:21: Pleroma.Web.Preload.Providers.Instance.generate_terms/1 (pleroma 2.7.0-3067-g9b76dbd4-dev-lanodan2) lib/pleroma/web/preload.ex:13: anonymous fn/3 in Pleroma.Web.Preload.build_tags/2 --- changelog.d/vapid_keyword_fallback.fix | 1 + lib/pleroma/web/push.ex | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/vapid_keyword_fallback.fix diff --git a/changelog.d/vapid_keyword_fallback.fix b/changelog.d/vapid_keyword_fallback.fix new file mode 100644 index 000000000..aa48f8938 --- /dev/null +++ b/changelog.d/vapid_keyword_fallback.fix @@ -0,0 +1 @@ +Make vapid_config return empty array, fixing preloading for instances without push notifications configured \ No newline at end of file diff --git a/lib/pleroma/web/push.ex b/lib/pleroma/web/push.ex index 6d777142e..77f77f88e 100644 --- a/lib/pleroma/web/push.ex +++ b/lib/pleroma/web/push.ex @@ -20,7 +20,7 @@ defmodule Pleroma.Web.Push do end def vapid_config do - Application.get_env(:web_push_encryption, :vapid_details, nil) + Application.get_env(:web_push_encryption, :vapid_details, []) end def enabled, do: match?([subject: _, public_key: _, private_key: _], vapid_config()) From 382426e0338d7918cd2db7c72ede446a2a8f7f4f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 12:41:06 -0400 Subject: [PATCH 136/212] Remove Object.get_by_id_and_maybe_refetch/2 This was only used for poll refreshing and is not a good approach to the problem. --- lib/pleroma/object.ex | 21 --- .../controllers/poll_controller.ex | 2 +- test/pleroma/object_test.exs | 144 ------------------ 3 files changed, 1 insertion(+), 166 deletions(-) diff --git a/lib/pleroma/object.ex b/lib/pleroma/object.ex index 748f18e6c..77dfda851 100644 --- a/lib/pleroma/object.ex +++ b/lib/pleroma/object.ex @@ -99,27 +99,6 @@ defmodule Pleroma.Object do def get_by_id(nil), do: nil def get_by_id(id), do: Repo.get(Object, id) - @spec get_by_id_and_maybe_refetch(integer(), list()) :: Object.t() | nil - def get_by_id_and_maybe_refetch(id, opts \\ []) do - with %Object{updated_at: updated_at} = object <- get_by_id(id) do - if opts[:interval] && - NaiveDateTime.diff(NaiveDateTime.utc_now(), updated_at) > opts[:interval] do - case Fetcher.refetch_object(object) do - {:ok, %Object{} = object} -> - object - - e -> - Logger.error("Couldn't refresh #{object.data["id"]}:\n#{inspect(e)}") - object - end - else - object - end - else - nil -> nil - end - end - def get_by_ap_id(nil), do: nil def get_by_ap_id(ap_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 a2af8148c..303b995f6 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -30,7 +30,7 @@ defmodule Pleroma.Web.MastodonAPI.PollController do @doc "GET /api/v1/polls/:id" def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do - with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60), + with %Object{} = 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) do try_render(conn, "show.json", %{object: object, for: user}) diff --git a/test/pleroma/object_test.exs b/test/pleroma/object_test.exs index 48d4d86eb..b3c528e32 100644 --- a/test/pleroma/object_test.exs +++ b/test/pleroma/object_test.exs @@ -6,12 +6,10 @@ defmodule Pleroma.ObjectTest do use Pleroma.DataCase use Oban.Testing, repo: Pleroma.Repo - import ExUnit.CaptureLog import Mox import Pleroma.Factory import Tesla.Mock - alias Pleroma.Activity alias Pleroma.Hashtag alias Pleroma.Object alias Pleroma.Repo @@ -282,148 +280,6 @@ defmodule Pleroma.ObjectTest do end end - describe "get_by_id_and_maybe_refetch" do - setup do - mock(fn - %{method: :get, url: "https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d"} -> - %Tesla.Env{ - status: 200, - body: File.read!("test/fixtures/tesla_mock/poll_original.json"), - headers: HttpRequestMock.activitypub_object_headers() - } - - env -> - apply(HttpRequestMock, :request, [env]) - end) - - mock_modified = fn resp -> - mock(fn - %{method: :get, url: "https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d"} -> - resp - - env -> - apply(HttpRequestMock, :request, [env]) - end) - end - - on_exit(fn -> mock(fn env -> apply(HttpRequestMock, :request, [env]) end) end) - - [mock_modified: mock_modified] - end - - test "refetches if the time since the last refetch is greater than the interval", %{ - mock_modified: mock_modified - } do - %Object{} = - object = - Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d", - fetch: true - ) - - Object.set_cache(object) - - assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4 - assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0 - - mock_modified.(%Tesla.Env{ - status: 200, - body: File.read!("test/fixtures/tesla_mock/poll_modified.json"), - headers: HttpRequestMock.activitypub_object_headers() - }) - - updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: -1) - object_in_cache = Object.get_cached_by_ap_id(object.data["id"]) - assert updated_object == object_in_cache - assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 8 - assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 3 - end - - test "returns the old object if refetch fails", %{mock_modified: mock_modified} do - %Object{} = - object = - Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d", - fetch: true - ) - - Object.set_cache(object) - - assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4 - assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0 - - assert capture_log(fn -> - mock_modified.(%Tesla.Env{status: 404, body: ""}) - - updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: -1) - object_in_cache = Object.get_cached_by_ap_id(object.data["id"]) - assert updated_object == object_in_cache - assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 4 - assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 0 - end) =~ - "[error] Couldn't refresh https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d" - end - - test "does not refetch if the time since the last refetch is greater than the interval", %{ - mock_modified: mock_modified - } do - %Object{} = - object = - Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d", - fetch: true - ) - - Object.set_cache(object) - - assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4 - assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0 - - mock_modified.(%Tesla.Env{ - status: 200, - body: File.read!("test/fixtures/tesla_mock/poll_modified.json"), - headers: HttpRequestMock.activitypub_object_headers() - }) - - updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: 100) - object_in_cache = Object.get_cached_by_ap_id(object.data["id"]) - assert updated_object == object_in_cache - assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 4 - assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 0 - end - - test "preserves internal fields on refetch", %{mock_modified: mock_modified} do - %Object{} = - object = - Object.normalize("https://patch.cx/objects/9a172665-2bc5-452d-8428-2361d4c33b1d", - fetch: true - ) - - Object.set_cache(object) - - assert Enum.at(object.data["oneOf"], 0)["replies"]["totalItems"] == 4 - assert Enum.at(object.data["oneOf"], 1)["replies"]["totalItems"] == 0 - - user = insert(:user) - activity = Activity.get_create_by_object_ap_id(object.data["id"]) - {:ok, activity} = CommonAPI.favorite(activity.id, user) - object = Object.get_by_ap_id(activity.data["object"]) - - assert object.data["like_count"] == 1 - - mock_modified.(%Tesla.Env{ - status: 200, - body: File.read!("test/fixtures/tesla_mock/poll_modified.json"), - headers: HttpRequestMock.activitypub_object_headers() - }) - - updated_object = Object.get_by_id_and_maybe_refetch(object.id, interval: -1) - object_in_cache = Object.get_cached_by_ap_id(object.data["id"]) - assert updated_object == object_in_cache - assert Enum.at(updated_object.data["oneOf"], 0)["replies"]["totalItems"] == 8 - assert Enum.at(updated_object.data["oneOf"], 1)["replies"]["totalItems"] == 3 - - assert updated_object.data["like_count"] == 1 - end - end - describe ":hashtags association" do test "Hashtag records are created with Object record and updated on its change" do user = insert(:user) From 2380ae6dcc267d7d6ff81a55ae95eed718176563 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 13:38:13 -0400 Subject: [PATCH 137/212] Validate an Oban job is inserted for poll refreshes --- .../web/mastodon_api/controllers/poll_controller_test.exs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs index 7912b1d5f..b2cceec51 100644 --- a/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs @@ -3,6 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.MastodonAPI.PollControllerTest do + use Oban.Testing, repo: Pleroma.Repo use Pleroma.Web.ConnCase, async: true alias Pleroma.Object @@ -27,6 +28,11 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do response = json_response_and_validate_schema(conn, 200) id = to_string(object.id) assert %{"id" => ^id, "expired" => false, "multiple" => false} = response + + assert_enqueued( + worker: Pleroma.Workers.PollWorker, + args: %{"op" => "refresh", "activity_id" => activity.id} + ) end test "does not expose polls for private statuses", %{conn: conn} do From c077a14ce1343f5515fa11938df7d808f23a566c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 13:54:56 -0400 Subject: [PATCH 138/212] Add Oban job to handle poll refreshing and stream out the update --- .../controllers/poll_controller.ex | 4 +++ lib/pleroma/workers/poll_worker.ex | 36 ++++++++++++++----- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index 303b995f6..0d5a57518 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.PollController do alias Pleroma.Activity alias Pleroma.Object + alias Pleroma.Workers.PollWorker alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI alias Pleroma.Web.Plugs.OAuthScopesPlug @@ -33,6 +34,9 @@ defmodule Pleroma.Web.MastodonAPI.PollController do with %Object{} = 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) do + PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) + |> Oban.insert(unique: [period: 60]) + try_render(conn, "show.json", %{object: object, for: user}) else error when is_nil(error) or error == false -> diff --git a/lib/pleroma/workers/poll_worker.ex b/lib/pleroma/workers/poll_worker.ex index d263aa1b9..0d2d67326 100644 --- a/lib/pleroma/workers/poll_worker.ex +++ b/lib/pleroma/workers/poll_worker.ex @@ -11,14 +11,34 @@ defmodule Pleroma.Workers.PollWorker do alias Pleroma.Activity alias Pleroma.Notification alias Pleroma.Object + alias Pleroma.Object.Fetcher + + @stream_out_impl Pleroma.Config.get( + [__MODULE__, :stream_out], + Pleroma.Web.ActivityPub.ActivityPub + ) @impl true def perform(%Job{args: %{"op" => "poll_end", "activity_id" => activity_id}}) do - with %Activity{} = activity <- find_poll_activity(activity_id), + with {_, %Activity{} = activity} <- {:activity, Activity.get_by_id(activity_id)}, {:ok, notifications} <- Notification.create_poll_notifications(activity) do Notification.stream(notifications) else - {:error, :poll_activity_not_found} = e -> {:cancel, e} + {:activity, nil} -> {:cancel, :poll_activity_not_found} + e -> {:error, e} + end + end + + def perform(%Job{args: %{"op" => "refresh", "activity_id" => activity_id}}) do + with {_, %Activity{object: object}} <- + {:activity, Activity.get_by_id_with_object(activity_id)}, + {_, {:ok, _object}} <- {:refetch, Fetcher.refetch_object(object)} do + stream_update(activity_id) + + :ok + else + {:activity, nil} -> {:cancel, :poll_activity_not_found} + {:refetch, _} = e -> {:cancel, e} e -> {:error, e} end end @@ -26,12 +46,6 @@ defmodule Pleroma.Workers.PollWorker do @impl true def timeout(_job), do: :timer.seconds(5) - defp find_poll_activity(activity_id) do - with nil <- Activity.get_by_id(activity_id) do - {:error, :poll_activity_not_found} - end - end - def schedule_poll_end(%Activity{data: %{"type" => "Create"}, id: activity_id} = activity) do with %Object{data: %{"type" => "Question", "closed" => closed}} when is_binary(closed) <- Object.normalize(activity), @@ -49,4 +63,10 @@ defmodule Pleroma.Workers.PollWorker do end def schedule_poll_end(activity), do: {:error, activity} + + defp stream_update(activity_id) do + Activity.get_by_id(activity_id) + |> Activity.normalize() + |> @stream_out_impl.stream_out() + end end From 4b3f604f9529c9ced23f747cb6f6d82fedfadab0 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 14:02:41 -0400 Subject: [PATCH 139/212] Skip refetching poll results if the object's updated_at is newer than the poll closed timestamp --- lib/pleroma/workers/poll_worker.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/pleroma/workers/poll_worker.ex b/lib/pleroma/workers/poll_worker.ex index 0d2d67326..a61c5eac1 100644 --- a/lib/pleroma/workers/poll_worker.ex +++ b/lib/pleroma/workers/poll_worker.ex @@ -32,6 +32,8 @@ defmodule Pleroma.Workers.PollWorker do def perform(%Job{args: %{"op" => "refresh", "activity_id" => activity_id}}) do with {_, %Activity{object: object}} <- {:activity, Activity.get_by_id_with_object(activity_id)}, + {:ok, naive_closed} <- NaiveDateTime.from_iso8601(object.data["closed"]), + {_, :lt} <- {:closed_compare, NaiveDateTime.compare(object.updated_at, naive_closed)}, {_, {:ok, _object}} <- {:refetch, Fetcher.refetch_object(object)} do stream_update(activity_id) @@ -39,6 +41,7 @@ defmodule Pleroma.Workers.PollWorker do else {:activity, nil} -> {:cancel, :poll_activity_not_found} {:refetch, _} = e -> {:cancel, e} + {:closed_compare, _} -> {:cancel, :poll_finalized} e -> {:error, e} end end From 47ce3a4a961bd7496f8105bc957dbf958b77d342 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 14:17:35 -0400 Subject: [PATCH 140/212] Schedule a final poll refresh before streaming out the notifications --- lib/pleroma/workers/poll_worker.ex | 8 ++++++-- test/pleroma/workers/poll_worker_test.exs | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/workers/poll_worker.ex b/lib/pleroma/workers/poll_worker.ex index a61c5eac1..574daa9ba 100644 --- a/lib/pleroma/workers/poll_worker.ex +++ b/lib/pleroma/workers/poll_worker.ex @@ -22,6 +22,10 @@ defmodule Pleroma.Workers.PollWorker do def perform(%Job{args: %{"op" => "poll_end", "activity_id" => activity_id}}) do with {_, %Activity{} = activity} <- {:activity, Activity.get_by_id(activity_id)}, {:ok, notifications} <- Notification.create_poll_notifications(activity) do + # Schedule a final refresh + __MODULE__.new(%{"op" => "refresh", "activity_id" => activity_id}) + |> Oban.insert() + Notification.stream(notifications) else {:activity, nil} -> {:cancel, :poll_activity_not_found} @@ -32,8 +36,8 @@ defmodule Pleroma.Workers.PollWorker do def perform(%Job{args: %{"op" => "refresh", "activity_id" => activity_id}}) do with {_, %Activity{object: object}} <- {:activity, Activity.get_by_id_with_object(activity_id)}, - {:ok, naive_closed} <- NaiveDateTime.from_iso8601(object.data["closed"]), - {_, :lt} <- {:closed_compare, NaiveDateTime.compare(object.updated_at, naive_closed)}, + {:ok, naive_closed} <- NaiveDateTime.from_iso8601(object.data["closed"]), + {_, :lt} <- {:closed_compare, NaiveDateTime.compare(object.updated_at, naive_closed)}, {_, {:ok, _object}} <- {:refetch, Fetcher.refetch_object(object)} do stream_update(activity_id) diff --git a/test/pleroma/workers/poll_worker_test.exs b/test/pleroma/workers/poll_worker_test.exs index 749df8aff..e1c67f057 100644 --- a/test/pleroma/workers/poll_worker_test.exs +++ b/test/pleroma/workers/poll_worker_test.exs @@ -44,6 +44,12 @@ defmodule Pleroma.Workers.PollWorkerTest do # Ensure notifications were streamed out when job executes assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], :_)) assert called(Pleroma.Web.Push.send(:_)) + + # Ensure we scheduled a final refresh of the poll + assert_enqueued( + worker: PollWorker, + args: %{"op" => "refresh", "activity_id" => activity.id} + ) end end end From a2e7db43aa3636569f4d770df980347a03c957fe Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 14:23:04 -0400 Subject: [PATCH 141/212] Rename assignment for consistency --- lib/pleroma/workers/poll_worker.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/workers/poll_worker.ex b/lib/pleroma/workers/poll_worker.ex index 574daa9ba..f70ab48a4 100644 --- a/lib/pleroma/workers/poll_worker.ex +++ b/lib/pleroma/workers/poll_worker.ex @@ -36,8 +36,8 @@ defmodule Pleroma.Workers.PollWorker do def perform(%Job{args: %{"op" => "refresh", "activity_id" => activity_id}}) do with {_, %Activity{object: object}} <- {:activity, Activity.get_by_id_with_object(activity_id)}, - {:ok, naive_closed} <- NaiveDateTime.from_iso8601(object.data["closed"]), - {_, :lt} <- {:closed_compare, NaiveDateTime.compare(object.updated_at, naive_closed)}, + {:ok, end_time} <- NaiveDateTime.from_iso8601(object.data["closed"]), + {_, :lt} <- {:closed_compare, NaiveDateTime.compare(object.updated_at, end_time)}, {_, {:ok, _object}} <- {:refetch, Fetcher.refetch_object(object)} do stream_update(activity_id) From 766edfe5b2b19f4819704540341b8fcc92f133bd Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 14:32:28 -0400 Subject: [PATCH 142/212] Test Poll refresh jobs stream out updates after refetching the object --- test/pleroma/workers/poll_worker_test.exs | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/pleroma/workers/poll_worker_test.exs b/test/pleroma/workers/poll_worker_test.exs index e1c67f057..56a338bac 100644 --- a/test/pleroma/workers/poll_worker_test.exs +++ b/test/pleroma/workers/poll_worker_test.exs @@ -52,4 +52,33 @@ defmodule Pleroma.Workers.PollWorkerTest do ) end end + + test "poll refresh job" do + user = insert(:user, local: false) + question = insert(:question, user: user) + activity = insert(:question_activity, question: question) + + PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) + |> Oban.insert() + + expected_job_args = %{"activity_id" => activity.id, "op" => "refresh"} + + assert_enqueued(args: expected_job_args) + + with_mocks([ + { + Pleroma.Web.Streamer, + [], + [ + stream: fn _, _ -> nil end + ] + } + ]) do + [job] = all_enqueued(worker: PollWorker) + PollWorker.perform(job) + + # Ensure updates are streamed out + assert called(Pleroma.Web.Streamer.stream(["user", "list", "public", "public:local"], :_)) + end + end end From b2340b5b776d243f6cf12971393783cc3b7c2dc2 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 14:45:13 -0400 Subject: [PATCH 143/212] Permit backdating the poll closed timestamp --- 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 8f1c6faf9..732ea3143 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -241,6 +241,7 @@ defmodule Pleroma.Factory do def question_factory(attrs \\ %{}) do user = attrs[:user] || insert(:user) + closed = attrs[:closed] || DateTime.utc_now() |> DateTime.add(86_400) |> DateTime.to_iso8601() data = %{ "id" => Pleroma.Web.ActivityPub.Utils.generate_object_id(), @@ -251,7 +252,7 @@ defmodule Pleroma.Factory do "to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => [user.follower_address], "context" => Pleroma.Web.ActivityPub.Utils.generate_context_id(), - "closed" => DateTime.utc_now() |> DateTime.add(86_400) |> DateTime.to_iso8601(), + "closed" => closed, "content" => "Which flavor of ice cream do you prefer?", "oneOf" => [ %{ From a1b384f63c3587d0463109b74b0bbcc5c5ae82ee Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 14:45:41 -0400 Subject: [PATCH 144/212] Test that a poll refresh is cancelled if updated_at on the object is newer than the poll closing time --- test/pleroma/workers/poll_worker_test.exs | 61 +++++++++++++++-------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/test/pleroma/workers/poll_worker_test.exs b/test/pleroma/workers/poll_worker_test.exs index 56a338bac..0fafcae11 100644 --- a/test/pleroma/workers/poll_worker_test.exs +++ b/test/pleroma/workers/poll_worker_test.exs @@ -53,32 +53,51 @@ defmodule Pleroma.Workers.PollWorkerTest do end end - test "poll refresh job" do - user = insert(:user, local: false) - question = insert(:question, user: user) - activity = insert(:question_activity, question: question) + describe "poll refresh" do + test "normal job" do + user = insert(:user, local: false) + question = insert(:question, user: user) + activity = insert(:question_activity, question: question) - PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) - |> Oban.insert() + PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) + |> Oban.insert() - expected_job_args = %{"activity_id" => activity.id, "op" => "refresh"} + expected_job_args = %{"activity_id" => activity.id, "op" => "refresh"} - assert_enqueued(args: expected_job_args) + assert_enqueued(args: expected_job_args) + + with_mocks([ + { + Pleroma.Web.Streamer, + [], + [ + stream: fn _, _ -> nil end + ] + } + ]) do + [job] = all_enqueued(worker: PollWorker) + PollWorker.perform(job) + + # Ensure updates are streamed out + assert called(Pleroma.Web.Streamer.stream(["user", "list", "public", "public:local"], :_)) + end + end + + test "when updated_at is after poll closing" do + poll_closed = DateTime.utc_now() |> DateTime.add(-86_400) |> DateTime.to_iso8601() + user = insert(:user, local: false) + question = insert(:question, user: user, closed: poll_closed) + activity = insert(:question_activity, question: question) + + PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) + |> Oban.insert() + + expected_job_args = %{"activity_id" => activity.id, "op" => "refresh"} + + assert_enqueued(args: expected_job_args) - with_mocks([ - { - Pleroma.Web.Streamer, - [], - [ - stream: fn _, _ -> nil end - ] - } - ]) do [job] = all_enqueued(worker: PollWorker) - PollWorker.perform(job) - - # Ensure updates are streamed out - assert called(Pleroma.Web.Streamer.stream(["user", "list", "public", "public:local"], :_)) + assert {:cancel, :poll_finalized} = PollWorker.perform(job) end end end From 2ab4049508148756076853bae26279b698740597 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 14:47:30 -0400 Subject: [PATCH 145/212] Poll refreshing changelog --- changelog.d/poll-refresh.change | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/poll-refresh.change diff --git a/changelog.d/poll-refresh.change b/changelog.d/poll-refresh.change new file mode 100644 index 000000000..b755128a1 --- /dev/null +++ b/changelog.d/poll-refresh.change @@ -0,0 +1 @@ +Poll results refreshing is handled asynchronously and will not attempt to keep fetching updates to a closed poll. From b735d9e6e19a1c64f43428e6342e3d172728c736 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 14:55:38 -0400 Subject: [PATCH 146/212] Improve assertion --- test/pleroma/workers/poll_worker_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pleroma/workers/poll_worker_test.exs b/test/pleroma/workers/poll_worker_test.exs index 0fafcae11..c34647f1b 100644 --- a/test/pleroma/workers/poll_worker_test.exs +++ b/test/pleroma/workers/poll_worker_test.exs @@ -97,7 +97,7 @@ defmodule Pleroma.Workers.PollWorkerTest do assert_enqueued(args: expected_job_args) [job] = all_enqueued(worker: PollWorker) - assert {:cancel, :poll_finalized} = PollWorker.perform(job) + assert {:cancel, :poll_finalized} == PollWorker.perform(job) end end end From 9ff57946e7d6fa7dabaf90457e11041ce46991c4 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Mon, 30 Sep 2024 15:25:13 -0400 Subject: [PATCH 147/212] Credo --- lib/pleroma/web/mastodon_api/controllers/poll_controller.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index 0d5a57518..f89bfa7f2 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -9,10 +9,10 @@ defmodule Pleroma.Web.MastodonAPI.PollController do alias Pleroma.Activity alias Pleroma.Object - alias Pleroma.Workers.PollWorker alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.CommonAPI alias Pleroma.Web.Plugs.OAuthScopesPlug + alias Pleroma.Workers.PollWorker action_fallback(Pleroma.Web.MastodonAPI.FallbackController) From 0a42a3f2eaf53fa87d934226874de5919320de26 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 2 Oct 2024 11:05:17 -0400 Subject: [PATCH 148/212] Do not attempt to schedule poll refresh jobs for local activities --- .../controllers/poll_controller.ex | 6 ++++-- .../controllers/poll_controller_test.exs | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index f89bfa7f2..495f89278 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -34,8 +34,10 @@ defmodule Pleroma.Web.MastodonAPI.PollController do with %Object{} = 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) do - PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) - |> Oban.insert(unique: [period: 60]) + unless activity.local do + PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) + |> Oban.insert(unique: [period: 60]) + end try_render(conn, "show.json", %{object: object, for: user}) else diff --git a/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs index b2cceec51..4b236678c 100644 --- a/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs @@ -29,6 +29,25 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do id = to_string(object.id) assert %{"id" => ^id, "expired" => false, "multiple" => false} = response + refute_enqueued( + worker: Pleroma.Workers.PollWorker, + args: %{"op" => "refresh", "activity_id" => activity.id} + ) + end + + test "does not create oban job to refresh poll if activity is local", %{conn: conn} do + user = insert(:user, local: false) + question = insert(:question, user: user) + activity = insert(:question_activity, question: question, local: false) + + # Ensure this is not represented as a local activity + refute activity.local + + object = Object.normalize(activity, fetch: false) + + get(conn, "/api/v1/polls/#{object.id}") + |> json_response_and_validate_schema(200) + assert_enqueued( worker: Pleroma.Workers.PollWorker, args: %{"op" => "refresh", "activity_id" => activity.id} From 35bd1977335a2bf73207f22aecbaead6e3112a1c Mon Sep 17 00:00:00 2001 From: tusooa Date: Wed, 2 Oct 2024 18:39:14 -0400 Subject: [PATCH 149/212] Fix nonexisting user will not generate metadata for search engine opt-out --- changelog.d/se-opt-out.change | 1 + lib/pleroma/web/fallback/redirect_controller.ex | 2 +- lib/pleroma/web/feed/user_controller.ex | 4 ++-- lib/pleroma/web/metadata/providers/feed.ex | 3 +++ lib/pleroma/web/metadata/providers/open_graph.ex | 3 +++ lib/pleroma/web/metadata/providers/rel_me.ex | 3 +++ lib/pleroma/web/metadata/providers/twitter_card.ex | 3 +++ test/pleroma/web/fallback_test.exs | 2 +- test/pleroma/web/feed/user_controller_test.exs | 9 +++++++++ 9 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 changelog.d/se-opt-out.change diff --git a/changelog.d/se-opt-out.change b/changelog.d/se-opt-out.change new file mode 100644 index 000000000..dd694033f --- /dev/null +++ b/changelog.d/se-opt-out.change @@ -0,0 +1 @@ +Fix nonexisting user will not generate metadata for search engine opt-out diff --git a/lib/pleroma/web/fallback/redirect_controller.ex b/lib/pleroma/web/fallback/redirect_controller.ex index 4a0885fab..6637848a9 100644 --- a/lib/pleroma/web/fallback/redirect_controller.ex +++ b/lib/pleroma/web/fallback/redirect_controller.ex @@ -46,7 +46,7 @@ defmodule Pleroma.Web.Fallback.RedirectController do redirector_with_meta(conn, %{user: user}) else nil -> - redirector(conn, params) + redirector_with_meta(conn, Map.delete(params, "maybe_nickname_or_id")) end end diff --git a/lib/pleroma/web/feed/user_controller.ex b/lib/pleroma/web/feed/user_controller.ex index 6657c2b3e..304313068 100644 --- a/lib/pleroma/web/feed/user_controller.ex +++ b/lib/pleroma/web/feed/user_controller.ex @@ -15,11 +15,11 @@ defmodule Pleroma.Web.Feed.UserController do action_fallback(:errors) - def feed_redirect(%{assigns: %{format: "html"}} = conn, %{"nickname" => nickname}) do + def feed_redirect(%{assigns: %{format: "html"}} = conn, %{"nickname" => nickname} = params) do with {_, %User{} = user} <- {:fetch_user, User.get_cached_by_nickname_or_id(nickname)} do Pleroma.Web.Fallback.RedirectController.redirector_with_meta(conn, %{user: user}) else - _ -> Pleroma.Web.Fallback.RedirectController.redirector(conn, nil) + _ -> Pleroma.Web.Fallback.RedirectController.redirector_with_meta(conn, params) end end diff --git a/lib/pleroma/web/metadata/providers/feed.ex b/lib/pleroma/web/metadata/providers/feed.ex index e97d6a54f..eb84b267f 100644 --- a/lib/pleroma/web/metadata/providers/feed.ex +++ b/lib/pleroma/web/metadata/providers/feed.ex @@ -20,4 +20,7 @@ defmodule Pleroma.Web.Metadata.Providers.Feed do ], []} ] end + + @impl Provider + def build_tags(_), do: [] end diff --git a/lib/pleroma/web/metadata/providers/open_graph.ex b/lib/pleroma/web/metadata/providers/open_graph.ex index 97d3865ed..fa5fbe553 100644 --- a/lib/pleroma/web/metadata/providers/open_graph.ex +++ b/lib/pleroma/web/metadata/providers/open_graph.ex @@ -67,6 +67,9 @@ defmodule Pleroma.Web.Metadata.Providers.OpenGraph do end end + @impl Provider + def build_tags(_), do: [] + defp build_attachments(%{data: %{"attachment" => attachments}}) do Enum.reduce(attachments, [], fn attachment, acc -> rendered_tags = diff --git a/lib/pleroma/web/metadata/providers/rel_me.ex b/lib/pleroma/web/metadata/providers/rel_me.ex index eabd8cb00..39aa71f06 100644 --- a/lib/pleroma/web/metadata/providers/rel_me.ex +++ b/lib/pleroma/web/metadata/providers/rel_me.ex @@ -20,6 +20,9 @@ defmodule Pleroma.Web.Metadata.Providers.RelMe do end) end + @impl Provider + def build_tags(_), do: [] + defp append_fields_tag(bio, fields) do fields |> Enum.reduce(bio, fn %{"value" => v}, res -> res <> v end) diff --git a/lib/pleroma/web/metadata/providers/twitter_card.ex b/lib/pleroma/web/metadata/providers/twitter_card.ex index 426022c65..7f50877c3 100644 --- a/lib/pleroma/web/metadata/providers/twitter_card.ex +++ b/lib/pleroma/web/metadata/providers/twitter_card.ex @@ -44,6 +44,9 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCard do end end + @impl Provider + def build_tags(_), do: [] + defp title_tag(user) do {:meta, [name: "twitter:title", content: Utils.user_name_string(user)], []} end diff --git a/test/pleroma/web/fallback_test.exs b/test/pleroma/web/fallback_test.exs index ed34d6490..9184cf8f1 100644 --- a/test/pleroma/web/fallback_test.exs +++ b/test/pleroma/web/fallback_test.exs @@ -32,7 +32,7 @@ defmodule Pleroma.Web.FallbackTest do resp = get(conn, "/foo") assert html_response(resp, 200) =~ "a cool title" - refute html_response(resp, 200) =~ "initial-results" + assert html_response(resp, 200) =~ "" end test "GET /*path", %{conn: conn} do diff --git a/test/pleroma/web/feed/user_controller_test.exs b/test/pleroma/web/feed/user_controller_test.exs index 1c17d47b4..0a3aaff5c 100644 --- a/test/pleroma/web/feed/user_controller_test.exs +++ b/test/pleroma/web/feed/user_controller_test.exs @@ -147,6 +147,15 @@ defmodule Pleroma.Web.Feed.UserControllerTest do assert response(conn, 404) end + test "returns noindex meta for missing user", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "text/html") + |> get("/users/nonexisting") + + assert html_response(conn, 200) =~ "" + end + test "returns feed with public and unlisted activities", %{conn: conn} do user = insert(:user) From ba2ae5e40bbe98d20be083d331222a9aea8b61de Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Oct 2024 10:14:02 -0400 Subject: [PATCH 150/212] Check if a refresh is permitted by comparing timestamps before attempting to insert an Oban job It's better to avoid inserting an Oban job that will just be rejected if it's not expensive to check. --- .../mastodon_api/controllers/poll_controller.ex | 17 ++++++++++++----- lib/pleroma/workers/poll_worker.ex | 3 --- .../controllers/poll_controller_test.exs | 5 ++++- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index 495f89278..4b347a6a7 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -32,12 +32,10 @@ defmodule Pleroma.Web.MastodonAPI.PollController do @doc "GET /api/v1/polls/:id" def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do with %Object{} = object <- Object.get_by_id(id), - %Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]), + %Activity{} = activity <- + Activity.get_create_by_object_ap_id_with_object(object.data["id"]), true <- Visibility.visible_for_user?(activity, user) do - unless activity.local do - PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) - |> Oban.insert(unique: [period: 60]) - end + maybe_refresh_poll(activity) try_render(conn, "show.json", %{object: object, for: user}) else @@ -76,4 +74,13 @@ defmodule Pleroma.Web.MastodonAPI.PollController do end end) end + + defp maybe_refresh_poll(%Activity{object: %Object{} = object} = activity) do + with false <- activity.local, + {:ok, end_time} <- NaiveDateTime.from_iso8601(object.data["closed"]), + {_, :lt} <- {:closed_compare, NaiveDateTime.compare(object.updated_at, end_time)} do + PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) + |> Oban.insert(unique: [period: 60]) + end + end end diff --git a/lib/pleroma/workers/poll_worker.ex b/lib/pleroma/workers/poll_worker.ex index f70ab48a4..bb92634c9 100644 --- a/lib/pleroma/workers/poll_worker.ex +++ b/lib/pleroma/workers/poll_worker.ex @@ -36,8 +36,6 @@ defmodule Pleroma.Workers.PollWorker do def perform(%Job{args: %{"op" => "refresh", "activity_id" => activity_id}}) do with {_, %Activity{object: object}} <- {:activity, Activity.get_by_id_with_object(activity_id)}, - {:ok, end_time} <- NaiveDateTime.from_iso8601(object.data["closed"]), - {_, :lt} <- {:closed_compare, NaiveDateTime.compare(object.updated_at, end_time)}, {_, {:ok, _object}} <- {:refetch, Fetcher.refetch_object(object)} do stream_update(activity_id) @@ -45,7 +43,6 @@ defmodule Pleroma.Workers.PollWorker do else {:activity, nil} -> {:cancel, :poll_activity_not_found} {:refetch, _} = e -> {:cancel, e} - {:closed_compare, _} -> {:cancel, :poll_finalized} e -> {:error, e} end end diff --git a/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs index 4b236678c..51af87742 100644 --- a/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/poll_controller_test.exs @@ -29,13 +29,16 @@ defmodule Pleroma.Web.MastodonAPI.PollControllerTest do id = to_string(object.id) assert %{"id" => ^id, "expired" => false, "multiple" => false} = response + # Local activities should not generate an Oban job to refresh + assert activity.local + refute_enqueued( worker: Pleroma.Workers.PollWorker, args: %{"op" => "refresh", "activity_id" => activity.id} ) end - test "does not create oban job to refresh poll if activity is local", %{conn: conn} do + test "creates an oban job to refresh poll if activity is remote", %{conn: conn} do user = insert(:user, local: false) question = insert(:question, user: user) activity = insert(:question_activity, question: question, local: false) From fa8de790dfbdb2cc7de212be4ecdd2823048ba8f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Oct 2024 10:19:10 -0400 Subject: [PATCH 151/212] Remove test superceded by logic change We will not be inserting jobs that should be skipped due to updated_at --- test/pleroma/workers/poll_worker_test.exs | 61 ++++++++--------------- 1 file changed, 21 insertions(+), 40 deletions(-) diff --git a/test/pleroma/workers/poll_worker_test.exs b/test/pleroma/workers/poll_worker_test.exs index c34647f1b..70eb7c422 100644 --- a/test/pleroma/workers/poll_worker_test.exs +++ b/test/pleroma/workers/poll_worker_test.exs @@ -53,51 +53,32 @@ defmodule Pleroma.Workers.PollWorkerTest do end end - describe "poll refresh" do - test "normal job" do - user = insert(:user, local: false) - question = insert(:question, user: user) - activity = insert(:question_activity, question: question) + test "poll refresh" do + user = insert(:user, local: false) + question = insert(:question, user: user) + activity = insert(:question_activity, question: question) - PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) - |> Oban.insert() + PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) + |> Oban.insert() - expected_job_args = %{"activity_id" => activity.id, "op" => "refresh"} + expected_job_args = %{"activity_id" => activity.id, "op" => "refresh"} - assert_enqueued(args: expected_job_args) - - with_mocks([ - { - Pleroma.Web.Streamer, - [], - [ - stream: fn _, _ -> nil end - ] - } - ]) do - [job] = all_enqueued(worker: PollWorker) - PollWorker.perform(job) - - # Ensure updates are streamed out - assert called(Pleroma.Web.Streamer.stream(["user", "list", "public", "public:local"], :_)) - end - end - - test "when updated_at is after poll closing" do - poll_closed = DateTime.utc_now() |> DateTime.add(-86_400) |> DateTime.to_iso8601() - user = insert(:user, local: false) - question = insert(:question, user: user, closed: poll_closed) - activity = insert(:question_activity, question: question) - - PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) - |> Oban.insert() - - expected_job_args = %{"activity_id" => activity.id, "op" => "refresh"} - - assert_enqueued(args: expected_job_args) + assert_enqueued(args: expected_job_args) + with_mocks([ + { + Pleroma.Web.Streamer, + [], + [ + stream: fn _, _ -> nil end + ] + } + ]) do [job] = all_enqueued(worker: PollWorker) - assert {:cancel, :poll_finalized} == PollWorker.perform(job) + PollWorker.perform(job) + + # Ensure updates are streamed out + assert called(Pleroma.Web.Streamer.stream(["user", "list", "public", "public:local"], :_)) end end end From b854e3836fd22a2589a6a6b97478998675d72048 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Oct 2024 10:30:32 -0400 Subject: [PATCH 152/212] Remove pattern that can never match --- lib/pleroma/workers/poll_worker.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pleroma/workers/poll_worker.ex b/lib/pleroma/workers/poll_worker.ex index bb92634c9..7d69bea54 100644 --- a/lib/pleroma/workers/poll_worker.ex +++ b/lib/pleroma/workers/poll_worker.ex @@ -43,7 +43,6 @@ defmodule Pleroma.Workers.PollWorker do else {:activity, nil} -> {:cancel, :poll_activity_not_found} {:refetch, _} = e -> {:cancel, e} - e -> {:error, e} end end From a3038aa6a2189ced1e5c394a4e6e8be76f2644d0 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Thu, 3 Oct 2024 11:01:33 -0400 Subject: [PATCH 153/212] Increase poll refresh interval to 120 seconds --- lib/pleroma/web/mastodon_api/controllers/poll_controller.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex index 4b347a6a7..6526457df 100644 --- a/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/poll_controller.ex @@ -28,6 +28,7 @@ defmodule Pleroma.Web.MastodonAPI.PollController do defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PollOperation @cachex Pleroma.Config.get([:cachex, :provider], Cachex) + @poll_refresh_interval 120 @doc "GET /api/v1/polls/:id" def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do @@ -80,7 +81,7 @@ defmodule Pleroma.Web.MastodonAPI.PollController do {:ok, end_time} <- NaiveDateTime.from_iso8601(object.data["closed"]), {_, :lt} <- {:closed_compare, NaiveDateTime.compare(object.updated_at, end_time)} do PollWorker.new(%{"op" => "refresh", "activity_id" => activity.id}) - |> Oban.insert(unique: [period: 60]) + |> Oban.insert(unique: [period: @poll_refresh_interval]) end end end From 4533f171ab5b73e5fc332c8f65fcf1e39e4d6003 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 5 Nov 2022 13:56:56 -0500 Subject: [PATCH 154/212] Add RemoteReportPolicy to reject reports without enough information --- config/config.exs | 4 + .../activity_pub/mrf/remote_report_policy.ex | 80 +++++++++++++++++ .../mrf/remote_report_policy_test.exs | 85 +++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex create mode 100644 test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs diff --git a/config/config.exs b/config/config.exs index 47ddfac5a..203a61c75 100644 --- a/config/config.exs +++ b/config/config.exs @@ -434,6 +434,10 @@ config :pleroma, :mrf_follow_bot, follower_nickname: nil config :pleroma, :mrf_inline_quote, template: "RT: {url}" +config :pleroma, :mrf_remote_report, + reject_anonymous: true, + reject_empty_message: true + config :pleroma, :mrf_force_mention, mention_parent: true, mention_quoted: true diff --git a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex new file mode 100644 index 000000000..3cf47e3ed --- /dev/null +++ b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex @@ -0,0 +1,80 @@ +defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do + @moduledoc "Drop remote reports if they don't contain enough information." + @behaviour Pleroma.Web.ActivityPub.MRF.Policy + + alias Pleroma.Config + + @impl true + def filter(%{"type" => "Flag"} = object) do + with {_, false} <- {:local, local?(object)}, + {:ok, _} <- maybe_reject_anonymous(object), + {:ok, _} <- maybe_reject_empty_message(object) do + {:ok, object} + else + {:local, true} -> {:ok, object} + {:reject, message} -> {:reject, message} + error -> {:reject, error} + end + end + + def filter(object), do: {:ok, object} + + defp maybe_reject_anonymous(%{"actor" => actor} = object) do + with true <- Config.get([:mrf_remote_report, :reject_anonymous]), + %URI{path: "/actor"} <- URI.parse(actor) do + {:reject, "[RemoteReportPolicy] Anonymous: #{actor}"} + else + _ -> {:ok, object} + end + end + + defp maybe_reject_empty_message(%{"content" => content} = object) + when is_binary(content) and content != "" do + {:ok, object} + end + + defp maybe_reject_empty_message(object) do + if Config.get([:mrf_remote_report, :reject_empty_message]) do + {:reject, ["RemoteReportPolicy] No content"]} + else + {:ok, object} + end + end + + defp local?(%{"actor" => actor}) do + String.starts_with?(actor, Pleroma.Web.Endpoint.url()) + end + + @impl true + def describe do + mrf_remote_report = + Config.get(:mrf_remote_report) + |> Enum.into(%{}) + + {:ok, %{mrf_remote_report: mrf_remote_report}} + end + + @impl true + def config_description do + %{ + key: :mrf_remote_report, + related_policy: "Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy", + label: "MRF Remote Report", + description: "Drop remote reports if they don't contain enough information.", + children: [ + %{ + key: :reject_anonymous, + type: :boolean, + description: "Reject anonymous remote reports?", + suggestions: [true] + }, + %{ + key: :reject_empty_message, + type: :boolean, + description: "Reject remote reports with no message?", + suggestions: [true] + } + ] + } + end +end diff --git a/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs b/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs new file mode 100644 index 000000000..55fa0f2f2 --- /dev/null +++ b/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs @@ -0,0 +1,85 @@ +defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do + use Pleroma.DataCase, async: true + + alias Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy + + test "doesn't impact local report" do + clear_config([:mrf_remote_report, :reject_anonymous], true) + clear_config([:mrf_remote_report, :reject_empty_message], true) + + activity = %{ + "type" => "Flag", + "actor" => "http://localhost:4001/actor" + } + + assert {:ok, _} = RemoteReportPolicy.filter(activity) + end + + test "rejects anonymous report if `reject_anonymous: true`" do + clear_config([:mrf_remote_report, :reject_anonymous], true) + + activity = %{ + "type" => "Flag", + "actor" => "https://mastodon.social/actor" + } + + assert {:reject, _} = RemoteReportPolicy.filter(activity) + end + + test "preserves anonymous report if `reject_anonymous: false`" do + clear_config([:mrf_remote_report, :reject_anonymous], false) + + activity = %{ + "type" => "Flag", + "actor" => "https://mastodon.social/actor" + } + + assert {:ok, _} = RemoteReportPolicy.filter(activity) + end + + test "rejects empty message report if `reject_empty_message: true`" do + clear_config([:mrf_remote_report, :reject_empty_message], true) + + activity = %{ + "type" => "Flag", + "actor" => "https://mastodon.social/users/Gargron" + } + + assert {:reject, _} = RemoteReportPolicy.filter(activity) + end + + test "rejects empty message report (\"\") if `reject_empty_message: true`" do + clear_config([:mrf_remote_report, :reject_empty_message], true) + + activity = %{ + "type" => "Flag", + "actor" => "https://mastodon.social/users/Gargron", + "content" => "" + } + + assert {:reject, _} = RemoteReportPolicy.filter(activity) + end + + test "preserves empty message report if `reject_empty_message: false`" do + clear_config([:mrf_remote_report, :reject_empty_message], false) + + activity = %{ + "type" => "Flag", + "actor" => "https://mastodon.social/users/Gargron" + } + + assert {:ok, _} = RemoteReportPolicy.filter(activity) + end + + test "preserves anonymous, empty message report with all settings disabled" do + clear_config([:mrf_remote_report, :reject_empty_message], false) + clear_config([:mrf_remote_report, :reject_empty_message], false) + + activity = %{ + "type" => "Flag", + "actor" => "https://mastodon.social/actor" + } + + assert {:ok, _} = RemoteReportPolicy.filter(activity) + end +end From b7c91876d2cc027a5a7f8a79ba256f13af623997 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 5 Nov 2022 14:07:37 -0500 Subject: [PATCH 155/212] RemoteReportPolicy: add `:reject_all` option, fix tests --- config/config.exs | 1 + .../activity_pub/mrf/remote_report_policy.ex | 15 +++++++++++ .../mrf/remote_report_policy_test.exs | 25 ++++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/config/config.exs b/config/config.exs index 203a61c75..07e98011d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -435,6 +435,7 @@ config :pleroma, :mrf_follow_bot, follower_nickname: nil config :pleroma, :mrf_inline_quote, template: "RT: {url}" config :pleroma, :mrf_remote_report, + reject_all: false, reject_anonymous: true, reject_empty_message: true diff --git a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex index 3cf47e3ed..0bd83d8f0 100644 --- a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do @impl true def filter(%{"type" => "Flag"} = object) do with {_, false} <- {:local, local?(object)}, + {:ok, _} <- maybe_reject_all(object), {:ok, _} <- maybe_reject_anonymous(object), {:ok, _} <- maybe_reject_empty_message(object) do {:ok, object} @@ -19,6 +20,14 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do def filter(object), do: {:ok, object} + defp maybe_reject_all(object) do + if Config.get([:mrf_remote_report, :reject_all]) do + {:reject, "[RemoteReportPolicy] Remote report"} + else + {:ok, object} + end + end + defp maybe_reject_anonymous(%{"actor" => actor} = object) do with true <- Config.get([:mrf_remote_report, :reject_anonymous]), %URI{path: "/actor"} <- URI.parse(actor) do @@ -62,6 +71,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do label: "MRF Remote Report", description: "Drop remote reports if they don't contain enough information.", children: [ + %{ + key: :reject_all, + type: :boolean, + description: "Reject all remote reports? (this option takes precedence)", + suggestions: [false] + }, %{ key: :reject_anonymous, type: :boolean, diff --git a/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs b/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs index 55fa0f2f2..43258a7f6 100644 --- a/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs @@ -3,6 +3,10 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do alias Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy + setup do + clear_config([:mrf_remote_report, :reject_all], false) + end + test "doesn't impact local report" do clear_config([:mrf_remote_report, :reject_anonymous], true) clear_config([:mrf_remote_report, :reject_empty_message], true) @@ -17,6 +21,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do test "rejects anonymous report if `reject_anonymous: true`" do clear_config([:mrf_remote_report, :reject_anonymous], true) + clear_config([:mrf_remote_report, :reject_empty_message], true) activity = %{ "type" => "Flag", @@ -28,6 +33,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do test "preserves anonymous report if `reject_anonymous: false`" do clear_config([:mrf_remote_report, :reject_anonymous], false) + clear_config([:mrf_remote_report, :reject_empty_message], false) activity = %{ "type" => "Flag", @@ -38,6 +44,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do end test "rejects empty message report if `reject_empty_message: true`" do + clear_config([:mrf_remote_report, :reject_anonymous], false) clear_config([:mrf_remote_report, :reject_empty_message], true) activity = %{ @@ -49,6 +56,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do end test "rejects empty message report (\"\") if `reject_empty_message: true`" do + clear_config([:mrf_remote_report, :reject_anonymous], false) clear_config([:mrf_remote_report, :reject_empty_message], true) activity = %{ @@ -61,6 +69,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do end test "preserves empty message report if `reject_empty_message: false`" do + clear_config([:mrf_remote_report, :reject_anonymous], false) clear_config([:mrf_remote_report, :reject_empty_message], false) activity = %{ @@ -72,7 +81,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do end test "preserves anonymous, empty message report with all settings disabled" do - clear_config([:mrf_remote_report, :reject_empty_message], false) + clear_config([:mrf_remote_report, :reject_anonymous], false) clear_config([:mrf_remote_report, :reject_empty_message], false) activity = %{ @@ -82,4 +91,18 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do assert {:ok, _} = RemoteReportPolicy.filter(activity) end + + test "reject remote report if `reject_all: true`" do + clear_config([:mrf_remote_report, :reject_all], true) + clear_config([:mrf_remote_report, :reject_anonymous], false) + clear_config([:mrf_remote_report, :reject_empty_message], false) + + activity = %{ + "type" => "Flag", + "actor" => "https://mastodon.social/users/Gargron", + "content" => "Transphobia" + } + + assert {:reject, _} = RemoteReportPolicy.filter(activity) + end end From fd83b86b99ee6642fa0a765a55c0f0e35f272151 Mon Sep 17 00:00:00 2001 From: Mint Date: Tue, 12 Mar 2024 22:45:15 +0300 Subject: [PATCH 156/212] RemoteReportPolicy: add `reject_third_party` option --- .../activity_pub/mrf/remote_report_policy.ex | 22 +++++++++ .../mrf/remote_report_policy_test.exs | 48 ++++++++++++++++--- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex index 0bd83d8f0..964c59cbf 100644 --- a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do with {_, false} <- {:local, local?(object)}, {:ok, _} <- maybe_reject_all(object), {:ok, _} <- maybe_reject_anonymous(object), + {:ok, _} <- maybe_reject_third_party(object), {:ok, _} <- maybe_reject_empty_message(object) do {:ok, object} else @@ -37,6 +38,21 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do end end + defp maybe_reject_third_party(%{"object" => objects} = object) do + {_, to} = case objects do + [head | tail] when is_binary(head) -> {tail, head} + s when is_binary(s) -> {[], s} + _ -> {[], ""} + end + + with true <- Config.get([:mrf_remote_report, :reject_third_party]), + String.starts_with?(to, Pleroma.Web.Endpoint.url()) do + {:reject, "[RemoteReportPolicy] Third-party: #{to}"} + else + _ -> {:ok, object} + end + end + defp maybe_reject_empty_message(%{"content" => content} = object) when is_binary(content) and content != "" do {:ok, object} @@ -83,6 +99,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do description: "Reject anonymous remote reports?", suggestions: [true] }, + %{ + key: :reject_third_party, + type: :boolean, + description: "Reject reports on users from third-party instances?", + suggestions: [true] + }, %{ key: :reject_empty_message, type: :boolean, diff --git a/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs b/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs index 43258a7f6..dd56a1e9b 100644 --- a/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs @@ -13,7 +13,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do activity = %{ "type" => "Flag", - "actor" => "http://localhost:4001/actor" + "actor" => "http://localhost:4001/actor", + "object" => ["https://mastodon.online/users/Gargron"] } assert {:ok, _} = RemoteReportPolicy.filter(activity) @@ -25,7 +26,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do activity = %{ "type" => "Flag", - "actor" => "https://mastodon.social/actor" + "actor" => "https://mastodon.social/actor", + "object" => ["https://mastodon.online/users/Gargron"] } assert {:reject, _} = RemoteReportPolicy.filter(activity) @@ -37,7 +39,34 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do activity = %{ "type" => "Flag", - "actor" => "https://mastodon.social/actor" + "actor" => "https://mastodon.social/actor", + "object" => ["https://mastodon.online/users/Gargron"] + } + + assert {:ok, _} = RemoteReportPolicy.filter(activity) + end + + test "rejects report on third-party if `reject_third_party: true`" do + clear_config([:mrf_remote_report, :reject_third_party], true) + clear_config([:mrf_remote_report, :reject_empty_message], false) + + activity = %{ + "type" => "Flag", + "actor" => "https://mastodon.social/users/Gargron", + "object" => ["https://mastodon.online/users/Gargron"] + } + + assert {:reject, _} = RemoteReportPolicy.filter(activity) + end + + test "preserves report on third party if `reject_third_party: false`" do + clear_config([:mrf_remote_report, :reject_third_party], false) + clear_config([:mrf_remote_report, :reject_empty_message], false) + + activity = %{ + "type" => "Flag", + "actor" => "https://mastodon.social/users/Gargron", + "object" => ["https://mastodon.online/users/Gargron"] } assert {:ok, _} = RemoteReportPolicy.filter(activity) @@ -49,7 +78,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do activity = %{ "type" => "Flag", - "actor" => "https://mastodon.social/users/Gargron" + "actor" => "https://mastodon.social/users/Gargron", + "object" => ["https://mastodon.online/users/Gargron"] } assert {:reject, _} = RemoteReportPolicy.filter(activity) @@ -62,6 +92,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do activity = %{ "type" => "Flag", "actor" => "https://mastodon.social/users/Gargron", + "object" => ["https://mastodon.online/users/Gargron"], "content" => "" } @@ -74,7 +105,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do activity = %{ "type" => "Flag", - "actor" => "https://mastodon.social/users/Gargron" + "actor" => "https://mastodon.social/users/Gargron", + "object" => ["https://mastodon.online/users/Gargron"] } assert {:ok, _} = RemoteReportPolicy.filter(activity) @@ -86,7 +118,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do activity = %{ "type" => "Flag", - "actor" => "https://mastodon.social/actor" + "actor" => "https://mastodon.social/actor", + "object" => ["https://mastodon.online/users/Gargron"] } assert {:ok, _} = RemoteReportPolicy.filter(activity) @@ -100,7 +133,8 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do activity = %{ "type" => "Flag", "actor" => "https://mastodon.social/users/Gargron", - "content" => "Transphobia" + "content" => "Transphobia", + "object" => ["https://mastodon.online/users/Gargron"] } assert {:reject, _} = RemoteReportPolicy.filter(activity) From 55612cb8ee4908a2fbb200ff581bb07c7e43410a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 12 Mar 2024 15:52:33 -0500 Subject: [PATCH 157/212] mix format --- .../web/activity_pub/mrf/remote_report_policy.ex | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex index 964c59cbf..d33028931 100644 --- a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex @@ -39,11 +39,12 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do end defp maybe_reject_third_party(%{"object" => objects} = object) do - {_, to} = case objects do - [head | tail] when is_binary(head) -> {tail, head} - s when is_binary(s) -> {[], s} - _ -> {[], ""} - end + {_, to} = + case objects do + [head | tail] when is_binary(head) -> {tail, head} + s when is_binary(s) -> {[], s} + _ -> {[], ""} + end with true <- Config.get([:mrf_remote_report, :reject_third_party]), String.starts_with?(to, Pleroma.Web.Endpoint.url()) do From 48af6850fc2903d6f8c7cbf43b7db6b769c37a2a Mon Sep 17 00:00:00 2001 From: Mint Date: Fri, 12 Apr 2024 23:04:37 +0300 Subject: [PATCH 158/212] RemoteReportPolicy: Fix third-party report detection --- .../web/activity_pub/mrf/remote_report_policy.ex | 2 +- .../mrf/remote_report_policy_test.exs | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex index d33028931..fa0610bf1 100644 --- a/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex +++ b/lib/pleroma/web/activity_pub/mrf/remote_report_policy.ex @@ -47,7 +47,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicy do end with true <- Config.get([:mrf_remote_report, :reject_third_party]), - String.starts_with?(to, Pleroma.Web.Endpoint.url()) do + false <- String.starts_with?(to, Pleroma.Web.Endpoint.url()) do {:reject, "[RemoteReportPolicy] Third-party: #{to}"} else _ -> {:ok, object} diff --git a/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs b/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs index dd56a1e9b..8d2a6b4fa 100644 --- a/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs +++ b/test/pleroma/web/activity_pub/mrf/remote_report_policy_test.exs @@ -46,7 +46,7 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do assert {:ok, _} = RemoteReportPolicy.filter(activity) end - test "rejects report on third-party if `reject_third_party: true`" do + test "rejects report on third party if `reject_third_party: true`" do clear_config([:mrf_remote_report, :reject_third_party], true) clear_config([:mrf_remote_report, :reject_empty_message], false) @@ -59,6 +59,19 @@ defmodule Pleroma.Web.ActivityPub.MRF.RemoteReportPolicyTest do assert {:reject, _} = RemoteReportPolicy.filter(activity) end + test "preserves report on first party if `reject_third_party: true`" do + clear_config([:mrf_remote_report, :reject_third_party], true) + clear_config([:mrf_remote_report, :reject_empty_message], false) + + activity = %{ + "type" => "Flag", + "actor" => "https://mastodon.social/users/Gargron", + "object" => ["http://localhost:4001/actor"] + } + + assert {:ok, _} = RemoteReportPolicy.filter(activity) + end + test "preserves report on third party if `reject_third_party: false`" do clear_config([:mrf_remote_report, :reject_third_party], false) clear_config([:mrf_remote_report, :reject_empty_message], false) From eb971aa022f524f364daf24d6e6d617bfc5ca036 Mon Sep 17 00:00:00 2001 From: Mint Date: Thu, 3 Oct 2024 20:02:58 +0300 Subject: [PATCH 159/212] Changelog --- changelog.d/remote-report-policy.add | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/remote-report-policy.add diff --git a/changelog.d/remote-report-policy.add b/changelog.d/remote-report-policy.add new file mode 100644 index 000000000..1cf25b1a8 --- /dev/null +++ b/changelog.d/remote-report-policy.add @@ -0,0 +1 @@ +Added RemoteReportPolicy from Rebased for handling bogus federated reports From 0c41d986de973bfae82794b6fe499f8261a2f6e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 6 Oct 2024 17:00:39 +0200 Subject: [PATCH 160/212] Metadata: Do not include .atom feed links for remote accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- changelog.d/atom-tag.change | 1 + lib/pleroma/web/metadata/providers/feed.ex | 4 +++- test/pleroma/web/metadata/providers/feed_test.exs | 6 ++++++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 changelog.d/atom-tag.change diff --git a/changelog.d/atom-tag.change b/changelog.d/atom-tag.change new file mode 100644 index 000000000..1b3590dea --- /dev/null +++ b/changelog.d/atom-tag.change @@ -0,0 +1 @@ +Metadata: Do not include .atom feed links for remote accounts diff --git a/lib/pleroma/web/metadata/providers/feed.ex b/lib/pleroma/web/metadata/providers/feed.ex index e97d6a54f..3811f96f6 100644 --- a/lib/pleroma/web/metadata/providers/feed.ex +++ b/lib/pleroma/web/metadata/providers/feed.ex @@ -10,7 +10,7 @@ defmodule Pleroma.Web.Metadata.Providers.Feed do @behaviour Provider @impl Provider - def build_tags(%{user: user}) do + def build_tags(%{user: %{local: true} = user}) do [ {:link, [ @@ -20,4 +20,6 @@ defmodule Pleroma.Web.Metadata.Providers.Feed do ], []} ] end + + def build_tags(_), do: [] end diff --git a/test/pleroma/web/metadata/providers/feed_test.exs b/test/pleroma/web/metadata/providers/feed_test.exs index e593453da..40d9d0909 100644 --- a/test/pleroma/web/metadata/providers/feed_test.exs +++ b/test/pleroma/web/metadata/providers/feed_test.exs @@ -15,4 +15,10 @@ defmodule Pleroma.Web.Metadata.Providers.FeedTest do [rel: "alternate", type: "application/atom+xml", href: "/users/lain/feed.atom"], []} ] end + + test "it doesn't render a link to remote user's feed" do + user = insert(:user, nickname: "lain@lain.com", local: false) + + assert Feed.build_tags(%{user: user}) == [] + end end From f758b6e37c80f5adeba74009e1cc72a420937a30 Mon Sep 17 00:00:00 2001 From: tusooa Date: Tue, 8 Oct 2024 23:09:59 -0400 Subject: [PATCH 161/212] Fix incoming Blocks being rejected --- changelog.d/incoming-blocks.fix | 1 + lib/pleroma/constants.ex | 5 +++++ .../web/activity_pub/object_validator.ex | 12 +++++++++++ .../activity_pub_controller_test.exs | 21 +++++++++++++++++++ 4 files changed, 39 insertions(+) create mode 100644 changelog.d/incoming-blocks.fix diff --git a/changelog.d/incoming-blocks.fix b/changelog.d/incoming-blocks.fix new file mode 100644 index 000000000..3228d7318 --- /dev/null +++ b/changelog.d/incoming-blocks.fix @@ -0,0 +1 @@ +Fix incoming Block activities being rejected diff --git a/lib/pleroma/constants.ex b/lib/pleroma/constants.ex index 5268ebe7a..2828c79a9 100644 --- a/lib/pleroma/constants.ex +++ b/lib/pleroma/constants.ex @@ -87,6 +87,7 @@ defmodule Pleroma.Constants do const(activity_types, do: [ + "Block", "Create", "Update", "Delete", @@ -115,6 +116,10 @@ defmodule Pleroma.Constants do ] ) + const(object_types, + do: ~w[Event Question Answer Audio Video Image Article Note Page ChatMessage] + ) + # basic regex, just there to weed out potential mistakes # https://datatracker.ietf.org/doc/html/rfc2045#section-5.1 const(mime_regex, diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index b3043b93a..35774d410 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -11,6 +11,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do @behaviour Pleroma.Web.ActivityPub.ObjectValidator.Validating + import Pleroma.Constants, only: [activity_types: 0, object_types: 0] + alias Pleroma.Activity alias Pleroma.EctoType.ActivityPub.ObjectValidators alias Pleroma.Object @@ -38,6 +40,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do @impl true def validate(object, meta) + # This overload works together with the InboxGuardPlug + # and ensures that we are not accepting any activity type + # that cannot pass InboxGuardPlug. + # If we want to support any more activity types, make sure to + # add it in Pleroma.Constants's activity_types or object_types, + # and, if applicable, allowed_activity_types_from_strangers. + def validate(%{"type" => type}, _meta) + when type not in activity_types() and type not in object_types(), + do: {:error, :not_allowed_object_type} + def validate(%{"type" => "Block"} = block_activity, meta) do with {:ok, block_activity} <- block_activity diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index 3bd589f49..d4175b56f 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -1320,6 +1320,27 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do html_body: ~r/#{note.data["object"]}/i ) end + + test "it accepts an incoming Block", %{conn: conn, data: data} do + user = insert(:user) + + data = + data + |> Map.put("type", "Block") + |> Map.put("to", [user.ap_id]) + |> Map.put("cc", []) + |> Map.put("object", user.ap_id) + + conn = + conn + |> assign(:valid_signature, true) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{user.nickname}/inbox", data) + + assert "ok" == json_response(conn, 200) + ObanHelpers.perform(all_enqueued(worker: ReceiverWorker)) + assert Activity.get_by_ap_id(data["id"]) + end end describe "GET /users/:nickname/outbox" do From 37b1192b7bf7aa98fef0ca6c06d5b53719cb2e7b Mon Sep 17 00:00:00 2001 From: fzorb fzorbius Date: Wed, 9 Oct 2024 18:33:22 +0000 Subject: [PATCH 162/212] Should probably also include vips in the media/graphics packages section, as you need it to compile some library --- 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 02513daf2..920bc7d35 100644 --- a/docs/installation/freebsd_en.md +++ b/docs/installation/freebsd_en.md @@ -31,7 +31,7 @@ Setup the required services to automatically start at boot, using `sysrc(8)`. ### Install media / graphics packages (optional, see [`docs/installation/optional/media_graphics_packages.md`](../installation/optional/media_graphics_packages.md)) ```shell -# pkg install imagemagick ffmpeg p5-Image-ExifTool +# pkg install imagemagick ffmpeg p5-Image-ExifTool vips ``` ## Configuring Pleroma From 03a6e33b81281256f2e9b6ffb75910fdd1a7894f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 9 Oct 2024 16:25:58 -0400 Subject: [PATCH 163/212] Skip the final refresh job if the activity is local --- lib/pleroma/workers/poll_worker.ex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/workers/poll_worker.ex b/lib/pleroma/workers/poll_worker.ex index 7d69bea54..a9afe9d63 100644 --- a/lib/pleroma/workers/poll_worker.ex +++ b/lib/pleroma/workers/poll_worker.ex @@ -22,9 +22,11 @@ defmodule Pleroma.Workers.PollWorker do def perform(%Job{args: %{"op" => "poll_end", "activity_id" => activity_id}}) do with {_, %Activity{} = activity} <- {:activity, Activity.get_by_id(activity_id)}, {:ok, notifications} <- Notification.create_poll_notifications(activity) do - # Schedule a final refresh - __MODULE__.new(%{"op" => "refresh", "activity_id" => activity_id}) - |> Oban.insert() + unless activity.local do + # Schedule a final refresh + __MODULE__.new(%{"op" => "refresh", "activity_id" => activity_id}) + |> Oban.insert() + end Notification.stream(notifications) else From 5b04c2bf131f70120c407f5b4c242e3d245151f8 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Wed, 9 Oct 2024 20:15:00 -0400 Subject: [PATCH 164/212] Test the final refresh behavior of a PollWorker poll_end job --- test/pleroma/workers/poll_worker_test.exs | 32 ++++++++++++++++++++--- test/support/factory.ex | 3 ++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/test/pleroma/workers/poll_worker_test.exs b/test/pleroma/workers/poll_worker_test.exs index 70eb7c422..a7cbbdb83 100644 --- a/test/pleroma/workers/poll_worker_test.exs +++ b/test/pleroma/workers/poll_worker_test.exs @@ -11,10 +11,10 @@ defmodule Pleroma.Workers.PollWorkerTest do alias Pleroma.Workers.PollWorker - test "poll notification job" do + test "local poll ending notification job" do user = insert(:user) question = insert(:question, user: user) - activity = insert(:question_activity, question: question) + activity = insert(:question_activity, question: question, user: user) PollWorker.schedule_poll_end(activity) @@ -45,14 +45,38 @@ defmodule Pleroma.Workers.PollWorkerTest do assert called(Pleroma.Web.Streamer.stream(["user", "user:notification"], :_)) assert called(Pleroma.Web.Push.send(:_)) - # Ensure we scheduled a final refresh of the poll - assert_enqueued( + # Skip refreshing polls for local activities + assert activity.local + + refute_enqueued( worker: PollWorker, args: %{"op" => "refresh", "activity_id" => activity.id} ) end end + test "remote poll ending notification job schedules refresh" do + user = insert(:user, local: false) + question = insert(:question, user: user) + activity = insert(:question_activity, question: question, user: user) + + PollWorker.schedule_poll_end(activity) + + expected_job_args = %{"activity_id" => activity.id, "op" => "poll_end"} + + assert_enqueued(args: expected_job_args) + + [job] = all_enqueued(worker: PollWorker) + PollWorker.perform(job) + + refute activity.local + + assert_enqueued( + worker: PollWorker, + args: %{"op" => "refresh", "activity_id" => activity.id} + ) + end + test "poll refresh" do user = insert(:user, local: false) question = insert(:question, user: user) diff --git a/test/support/factory.ex b/test/support/factory.ex index 732ea3143..91e5805c8 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -510,7 +510,8 @@ defmodule Pleroma.Factory do %Pleroma.Activity{ data: data, actor: data["actor"], - recipients: data["to"] + recipients: data["to"], + local: user.local } |> Map.merge(attrs) end From 23f78c75738aac49a79de2834490048cde817669 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 11 Oct 2024 14:27:11 -0400 Subject: [PATCH 165/212] Refactor password changes to go through Pleroma.Web.Auth so they can be supported by the different auth backends --- lib/pleroma/web/auth/authenticator.ex | 5 ++++ lib/pleroma/web/auth/pleroma_authenticator.ex | 20 +++++++++++++ lib/pleroma/web/auth/wrapper_authenticator.ex | 4 +++ .../controllers/util_controller.ex | 29 ++++++++++--------- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/lib/pleroma/web/auth/authenticator.ex b/lib/pleroma/web/auth/authenticator.ex index 01bf1575c..95be892cd 100644 --- a/lib/pleroma/web/auth/authenticator.ex +++ b/lib/pleroma/web/auth/authenticator.ex @@ -10,4 +10,9 @@ defmodule Pleroma.Web.Auth.Authenticator do @callback handle_error(Plug.Conn.t(), any()) :: any() @callback auth_template() :: String.t() | nil @callback oauth_consumer_template() :: String.t() | nil + + @callback change_password(Pleroma.User.t(), String.t(), String.t(), String.t()) :: + {:ok, Pleroma.User.t()} | {:error, term()} + + @optional_callbacks change_password: 4 end diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index 09a58eb66..0da3f19fc 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -6,6 +6,7 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do alias Pleroma.Registration alias Pleroma.Repo alias Pleroma.User + alias Pleroma.Web.CommonAPI alias Pleroma.Web.Plugs.AuthenticationPlug import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1, fetch_user: 1] @@ -101,4 +102,23 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do def auth_template, do: nil def oauth_consumer_template, do: nil + + @doc "Changes Pleroma.User password in the database" + def change_password(user, password, new_password, new_password) do + case CommonAPI.Utils.confirm_current_password(user, password) do + {:ok, user} -> + with {:ok, _user} <- + User.reset_password(user, %{ + password: new_password, + password_confirmation: new_password + }) do + {:ok, user} + end + + error -> + error + end + end + + def change_password(_, _, _, _), do: {:error, :password_confirmation} end diff --git a/lib/pleroma/web/auth/wrapper_authenticator.ex b/lib/pleroma/web/auth/wrapper_authenticator.ex index a077cfa41..97b901036 100644 --- a/lib/pleroma/web/auth/wrapper_authenticator.ex +++ b/lib/pleroma/web/auth/wrapper_authenticator.ex @@ -39,4 +39,8 @@ defmodule Pleroma.Web.Auth.WrapperAuthenticator do implementation().oauth_consumer_template() || Pleroma.Config.get([:auth, :oauth_consumer_template], "consumer.html") end + + @impl true + def change_password(user, password, new_password, new_password_confirmation), + do: implementation().change_password(user, password, new_password, new_password_confirmation) end diff --git a/lib/pleroma/web/twitter_api/controllers/util_controller.ex b/lib/pleroma/web/twitter_api/controllers/util_controller.ex index 6805233df..aeafa195d 100644 --- a/lib/pleroma/web/twitter_api/controllers/util_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/util_controller.ex @@ -13,6 +13,7 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do alias Pleroma.Healthcheck alias Pleroma.User alias Pleroma.Web.ActivityPub.ActivityPub + alias Pleroma.Web.Auth.WrapperAuthenticator, as: Authenticator alias Pleroma.Web.CommonAPI alias Pleroma.Web.Plugs.OAuthScopesPlug alias Pleroma.Web.WebFinger @@ -195,19 +196,21 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do %{assigns: %{user: user}, private: %{open_api_spex: %{body_params: body_params}}} = conn, _ ) do - case CommonAPI.Utils.confirm_current_password(user, body_params.password) do - {:ok, user} -> - with {:ok, _user} <- - User.reset_password(user, %{ - password: body_params.new_password, - password_confirmation: body_params.new_password_confirmation - }) do - json(conn, %{status: "success"}) - else - {:error, changeset} -> - {_, {error, _}} = Enum.at(changeset.errors, 0) - json(conn, %{error: "New password #{error}."}) - end + with {:ok, %User{}} <- + Authenticator.change_password( + user, + body_params.password, + body_params.new_password, + body_params.new_password_confirmation + ) do + json(conn, %{status: "success"}) + else + {:error, %Ecto.Changeset{} = changeset} -> + {_, {error, _}} = Enum.at(changeset.errors, 0) + json(conn, %{error: "New password #{error}."}) + + {:error, :password_confirmation} -> + json(conn, %{error: "New password does not match confirmation."}) {:error, msg} -> json(conn, %{error: msg}) From 67cc38b5ac0cb009a38de3b182f34bbcb97467da Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 11 Oct 2024 15:39:38 -0400 Subject: [PATCH 166/212] Support password changes for LDAP auth backend --- lib/pleroma/ldap.ex | 30 +++++++++++++++++++--- lib/pleroma/web/auth/ldap_authenticator.ex | 9 +++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index 46a2d0c17..9c1263fcf 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -83,6 +83,12 @@ defmodule Pleroma.LDAP do end end + def handle_call({:change_password, name, password, new_password}, _from, state) do + result = change_password(state[:handle], name, password, new_password) + + {:reply, result, state, :hibernate} + end + @impl true def terminate(_, state) do handle = Keyword.get(state, :handle) @@ -162,17 +168,16 @@ defmodule Pleroma.LDAP do end defp bind_user(handle, name, password) do - uid = Config.get([:ldap, :uid], "cn") - base = Config.get([:ldap, :base]) + dn = make_dn(name) - case :eldap.simple_bind(handle, "#{uid}=#{name},#{base}", password) do + case :eldap.simple_bind(handle, dn, password) do :ok -> case fetch_user(name) do %User{} = user -> user _ -> - register_user(handle, base, uid, name) + register_user(handle, ldap_base(), ldap_uid(), name) end # eldap does not inform us of socket closure @@ -231,6 +236,14 @@ defmodule Pleroma.LDAP do end end + defp change_password(handle, name, password, new_password) do + dn = make_dn(name) + + with :ok <- :eldap.simple_bind(handle, dn, password) do + :eldap.modify_password(handle, dn, to_charlist(new_password), to_charlist(password)) + end + end + defp decode_certfile(file) do with {:ok, data} <- File.read(file) do data @@ -242,4 +255,13 @@ defmodule Pleroma.LDAP do [] end end + + defp ldap_uid, do: to_charlist(Config.get([:ldap, :uid], "cn")) + defp ldap_base, do: to_charlist(Config.get([:ldap, :base])) + + defp make_dn(name) do + uid = ldap_uid() + base = ldap_base() + ~c"#{uid}=#{name},#{base}" + end end diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index 7eb06183d..9bdf8447d 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -30,4 +30,13 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do error end end + + def change_password(user, password, new_password, new_password) do + case GenServer.call(LDAP, {:change_password, user.nickname, password, new_password}) do + :ok -> {:ok, user} + e -> e + end + end + + def change_password(_, _, _, _), do: {:error, :password_confirmation} end From ff039f953043d2c15f1eb44f794a77865ab5a775 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 11 Oct 2024 15:41:08 -0400 Subject: [PATCH 167/212] Add example OpenLDAP ldif to enable users to change their own passwords --- installation/openldap/pw_self_service.ldif | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 installation/openldap/pw_self_service.ldif diff --git a/installation/openldap/pw_self_service.ldif b/installation/openldap/pw_self_service.ldif new file mode 100644 index 000000000..463dabbfb --- /dev/null +++ b/installation/openldap/pw_self_service.ldif @@ -0,0 +1,7 @@ +dn: olcDatabase={1}mdb,cn=config +changetype: modify +add: olcAccess +olcAccess: {1}to attrs=userPassword + by self write + by anonymous auth + by * none From 6bc70b8b2a7c6942bfda01bfcc301a198cf3238b Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 11 Oct 2024 15:45:09 -0400 Subject: [PATCH 168/212] Add change_password/3 to LDAP module --- lib/pleroma/ldap.ex | 4 ++++ lib/pleroma/web/auth/ldap_authenticator.ex | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index 9c1263fcf..2bc894bd8 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -104,6 +104,10 @@ defmodule Pleroma.LDAP do GenServer.call(__MODULE__, {:bind_user, name, password}) end + def change_password(name, password, new_password) do + GenServer.call(__MODULE__, {:change_password, name, password, new_password}) + end + defp connect do ldap = Config.get(:ldap, []) host = Keyword.get(ldap, :host, "localhost") diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index 9bdf8447d..ec6601fb9 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -32,7 +32,7 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do end def change_password(user, password, new_password, new_password) do - case GenServer.call(LDAP, {:change_password, user.nickname, password, new_password}) do + case LDAP.change_password(user.nickname, password, new_password) do :ok -> {:ok, user} e -> e end From 1da057e6a4e4f8f7ddeb0ba286a3b996c1ba7710 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 11 Oct 2024 15:51:56 -0400 Subject: [PATCH 169/212] Reorganize the LDAP module --- lib/pleroma/ldap.ex | 50 ++++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex index 2bc894bd8..b591c2918 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/ldap.ex @@ -15,6 +15,14 @@ defmodule Pleroma.LDAP do GenServer.start_link(__MODULE__, [], name: __MODULE__) end + def bind_user(name, password) do + GenServer.call(__MODULE__, {:bind_user, name, password}) + end + + def change_password(name, password, new_password) do + GenServer.call(__MODULE__, {:change_password, name, password, new_password}) + end + @impl true def init(state) do case {Config.get(Pleroma.Web.Auth.Authenticator), Config.get([:ldap, :enabled])} do @@ -47,33 +55,16 @@ defmodule Pleroma.LDAP do def handle_info(:connect, _state), do: do_handle_connect() def handle_info({:bind_after_reconnect, name, password, from}, state) do - result = bind_user(state[:handle], name, password) + result = do_bind_user(state[:handle], name, password) GenServer.reply(from, result) {:noreply, state} end - defp do_handle_connect do - state = - case connect() do - {:ok, handle} -> - :eldap.controlling_process(handle, self()) - Process.link(handle) - [handle: handle] - - _ -> - Logger.error("Failed to connect to LDAP. Retrying in 5000ms") - Process.send_after(self(), :connect, 5_000) - [] - end - - {:noreply, state} - end - @impl true def handle_call({:bind_user, name, password}, from, state) do - case bind_user(state[:handle], name, password) do + case do_bind_user(state[:handle], name, password) do :needs_reconnect -> Process.send(self(), {:bind_after_reconnect, name, password, from}, []) {:noreply, state, {:continue, :connect}} @@ -100,12 +91,21 @@ defmodule Pleroma.LDAP do :ok end - def bind_user(name, password) do - GenServer.call(__MODULE__, {:bind_user, name, password}) - end + defp do_handle_connect do + state = + case connect() do + {:ok, handle} -> + :eldap.controlling_process(handle, self()) + Process.link(handle) + [handle: handle] - def change_password(name, password, new_password) do - GenServer.call(__MODULE__, {:change_password, name, password, new_password}) + _ -> + Logger.error("Failed to connect to LDAP. Retrying in 5000ms") + Process.send_after(self(), :connect, 5_000) + [] + end + + {:noreply, state} end defp connect do @@ -171,7 +171,7 @@ defmodule Pleroma.LDAP do end end - defp bind_user(handle, name, password) do + defp do_bind_user(handle, name, password) do dn = make_dn(name) case :eldap.simple_bind(handle, dn, password) do From b6a951cfb5e277aa265436674055a4d0b993c5b9 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 11 Oct 2024 16:20:38 -0400 Subject: [PATCH 170/212] LDAP password changing changelog --- changelog.d/ldap-password-change.add | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/ldap-password-change.add diff --git a/changelog.d/ldap-password-change.add b/changelog.d/ldap-password-change.add new file mode 100644 index 000000000..7ca555ee4 --- /dev/null +++ b/changelog.d/ldap-password-change.add @@ -0,0 +1 @@ +LDAP now supports users changing their passwords From 60ec42cb9c5f362e01ca2fb506ac153e00d5caa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 12 Oct 2024 23:45:18 +0200 Subject: [PATCH 171/212] Add metadata provider for ActivityPub alternate links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- changelog.d/activity-pub-metadata.add | 1 + lib/pleroma/web/metadata.ex | 1 + .../web/metadata/providers/activity_pub.ex | 19 +++++++++++ .../metadata/providers/activity_pub_test.exs | 34 +++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 changelog.d/activity-pub-metadata.add create mode 100644 lib/pleroma/web/metadata/providers/activity_pub.ex create mode 100644 test/pleroma/web/metadata/providers/activity_pub_test.exs diff --git a/changelog.d/activity-pub-metadata.add b/changelog.d/activity-pub-metadata.add new file mode 100644 index 000000000..2ad3d7b2d --- /dev/null +++ b/changelog.d/activity-pub-metadata.add @@ -0,0 +1 @@ +Add metadata provider for ActivityPub alternate links diff --git a/lib/pleroma/web/metadata.ex b/lib/pleroma/web/metadata.ex index 59d018730..4ee7c41ec 100644 --- a/lib/pleroma/web/metadata.ex +++ b/lib/pleroma/web/metadata.ex @@ -7,6 +7,7 @@ defmodule Pleroma.Web.Metadata do def build_tags(params) do providers = [ + Pleroma.Web.Metadata.Providers.ActivityPub, Pleroma.Web.Metadata.Providers.RelMe, Pleroma.Web.Metadata.Providers.RestrictIndexing | activated_providers() diff --git a/lib/pleroma/web/metadata/providers/activity_pub.ex b/lib/pleroma/web/metadata/providers/activity_pub.ex new file mode 100644 index 000000000..1759a5a0d --- /dev/null +++ b/lib/pleroma/web/metadata/providers/activity_pub.ex @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.ActivityPub do + alias Pleroma.Web.Metadata.Providers.Provider + + @behaviour Provider + + @impl Provider + def build_tags(%{object: %{data: %{"id" => object_id}}}) do + [{:link, [rel: "alternate", type: "application/activity+json", href: object_id], []}] + end + + @impl Provider + def build_tags(%{user: user}) do + [{:link, [rel: "alternate", type: "application/activity+json", href: user.ap_id], []}] + end +end diff --git a/test/pleroma/web/metadata/providers/activity_pub_test.exs b/test/pleroma/web/metadata/providers/activity_pub_test.exs new file mode 100644 index 000000000..c379ec092 --- /dev/null +++ b/test/pleroma/web/metadata/providers/activity_pub_test.exs @@ -0,0 +1,34 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2024 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.ActivityPubTest do + use Pleroma.DataCase + import Pleroma.Factory + + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Metadata.Providers.ActivityPub + + setup do: clear_config([Pleroma.Web.Metadata, :unfurl_nsfw]) + + test "it renders a link for user info" do + user = insert(:user) + res = ActivityPub.build_tags(%{user: user}) + + assert res == [ + {:link, [rel: "alternate", type: "application/activity+json", href: user.ap_id], []} + ] + end + + test "it renders a link for a post" do + user = insert(:user) + {:ok, %{id: activity_id, object: object}} = CommonAPI.post(user, %{status: "hi"}) + + result = ActivityPub.build_tags(%{object: object, user: user, activity_id: activity_id}) + + assert [ + {:link, + [rel: "alternate", type: "application/activity+json", href: object.data["id"]], []} + ] == result + end +end From f048637b41a81c527b6f3c0f5e90db91971f3842 Mon Sep 17 00:00:00 2001 From: Mark Jaroski Date: Mon, 21 Oct 2024 00:10:27 +0000 Subject: [PATCH 172/212] Some tidying and grammer improvements for these installation docs, based on my experience installing Pleroma on Ubuntu 24.04 a few minutes ago. --- changelog.d/debian-install-improve.skip | 1 + docs/installation/debian_based_en.md | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 changelog.d/debian-install-improve.skip diff --git a/changelog.d/debian-install-improve.skip b/changelog.d/debian-install-improve.skip new file mode 100644 index 000000000..6068a3066 --- /dev/null +++ b/changelog.d/debian-install-improve.skip @@ -0,0 +1 @@ +Fixed a formatting issue that had a required commend embedded in a textblock, and change the language to make it a bit more idiomatic. \ No newline at end of file diff --git a/docs/installation/debian_based_en.md b/docs/installation/debian_based_en.md index b61e4addd..21cfe2bff 100644 --- a/docs/installation/debian_based_en.md +++ b/docs/installation/debian_based_en.md @@ -69,12 +69,18 @@ cd /opt/pleroma sudo -Hu pleroma mix deps.get ``` -* Generate the configuration: `sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen` +* Generate the configuration: + +```shell +sudo -Hu pleroma MIX_ENV=prod mix pleroma.instance gen` +``` + +* During this process: * Answer with `yes` if it asks you to install `rebar3`. * This may take some time, because parts of pleroma get compiled first. * After that it will ask you a few questions about your instance and generates a configuration file in `config/generated_config.exs`. -* Check the configuration and if all looks right, rename it, so Pleroma will load it (`prod.secret.exs` for productive instance, `dev.secret.exs` for development instances): +* Check the configuration and if all looks right, rename it, so Pleroma will load it (`prod.secret.exs` for production instances, `dev.secret.exs` for development instances): ```shell sudo -Hu pleroma mv config/{generated_config.exs,prod.secret.exs} From e1296737a69703535a3688f2dd205821f0e9d073 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 22 Sep 2024 15:19:05 -0400 Subject: [PATCH 173/212] Disable busywaits in releases --- changelog.d/release-tuning.change | 1 + rel/vm.args.eex | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 changelog.d/release-tuning.change diff --git a/changelog.d/release-tuning.change b/changelog.d/release-tuning.change new file mode 100644 index 000000000..bf9abc3ad --- /dev/null +++ b/changelog.d/release-tuning.change @@ -0,0 +1 @@ +Tuning for release builds to lower CPU usage. diff --git a/rel/vm.args.eex b/rel/vm.args.eex index 71e803264..8e38fee4b 100644 --- a/rel/vm.args.eex +++ b/rel/vm.args.eex @@ -9,3 +9,8 @@ ## Tweak GC to run more often ##-env ERL_FULLSWEEP_AFTER 10 + +# Disable wasteful busywait. ++sbwt none ++sbwtdcpu none ++sbwtdio none From dc6362f71df2edc126cbebbb7f346ff4768c8451 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 25 Oct 2024 11:38:20 -0400 Subject: [PATCH 174/212] Changelog --- changelog.d/freebsd-docs.skip | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 changelog.d/freebsd-docs.skip diff --git a/changelog.d/freebsd-docs.skip b/changelog.d/freebsd-docs.skip new file mode 100644 index 000000000..e69de29bb From 63c6dacfcecd792cacbb8f735f52aa4912f23c9c Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 25 Oct 2024 11:38:20 -0400 Subject: [PATCH 175/212] Changelog --- changelog.d/docs-vips.skip | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 changelog.d/docs-vips.skip diff --git a/changelog.d/docs-vips.skip b/changelog.d/docs-vips.skip new file mode 100644 index 000000000..e69de29bb From 00b6a586acfbd60883fc1197fb5d5ed459606c9e Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 25 Oct 2024 11:56:55 -0400 Subject: [PATCH 176/212] OpenBSD needs libvips Confirmed package exists by testing an OpenBSD 7.6 arm64 VM --- docs/installation/openbsd_en.md | 2 +- docs/installation/openbsd_fi.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/installation/openbsd_en.md b/docs/installation/openbsd_en.md index e58e144d2..78bbf399f 100644 --- a/docs/installation/openbsd_en.md +++ b/docs/installation/openbsd_en.md @@ -12,7 +12,7 @@ For any additional information regarding commands and configuration files mentio To install them, run the following command (with doas or as root): ``` -pkg_add elixir gmake git postgresql-server postgresql-contrib cmake ffmpeg ImageMagick +pkg_add elixir gmake git postgresql-server postgresql-contrib cmake ffmpeg ImageMagick libvips ``` 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 73aca3a6f..d7c94d8a0 100644 --- a/docs/installation/openbsd_fi.md +++ b/docs/installation/openbsd_fi.md @@ -18,7 +18,7 @@ Matrix-kanava #pleroma:libera.chat ovat hyviä paikkoja löytää apua Asenna tarvittava ohjelmisto: -`# pkg_add git elixir gmake postgresql-server-10.3 postgresql-contrib-10.3 cmake ffmpeg ImageMagick` +`# pkg_add git elixir gmake postgresql-server-10.3 postgresql-contrib-10.3 cmake ffmpeg ImageMagick libvips` #### Optional software From 7d5ef8173735015c473fbc292a7f4b23d3e504a1 Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Sun, 27 Oct 2024 21:52:42 -0400 Subject: [PATCH 177/212] Fix /api/v2/media returning the wrong status code for media processed synchronously The API should return a 202 only if data cannot be returned yet and a followup GET /api/v1/media/:id should be called to retrieve it. This is something Mastodon does when it needs to transcode large media files. It does not apply to Pleroma and causes apps to waste an API call when posting a status which causes apps to appear to hang on higher latency environments, such as on mobile networks. https://docs.joinmastodon.org/methods/media/#v2 --- changelog.d/mediav2_status.fix | 1 + lib/pleroma/web/api_spec/operations/media_operation.ex | 2 +- lib/pleroma/web/mastodon_api/controllers/media_controller.ex | 4 +--- .../web/mastodon_api/controllers/media_controller_test.exs | 4 ++-- 4 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 changelog.d/mediav2_status.fix diff --git a/changelog.d/mediav2_status.fix b/changelog.d/mediav2_status.fix new file mode 100644 index 000000000..28e93e030 --- /dev/null +++ b/changelog.d/mediav2_status.fix @@ -0,0 +1 @@ +Fix /api/v2/media returning the wrong status code (202) for media processed synchronously diff --git a/lib/pleroma/web/api_spec/operations/media_operation.ex b/lib/pleroma/web/api_spec/operations/media_operation.ex index e6df21246..588b42e06 100644 --- a/lib/pleroma/web/api_spec/operations/media_operation.ex +++ b/lib/pleroma/web/api_spec/operations/media_operation.ex @@ -121,7 +121,7 @@ defmodule Pleroma.Web.ApiSpec.MediaOperation do security: [%{"oAuth" => ["write:media"]}], requestBody: Helpers.request_body("Parameters", create_request()), responses: %{ - 202 => Operation.response("Media", "application/json", Attachment), + 200 => Operation.response("Media", "application/json", Attachment), 400 => Operation.response("Media", "application/json", ApiError), 422 => Operation.response("Media", "application/json", ApiError), 500 => Operation.response("Media", "application/json", ApiError) diff --git a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex index 056bad844..41056d389 100644 --- a/lib/pleroma/web/mastodon_api/controllers/media_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/media_controller.ex @@ -53,9 +53,7 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do ) do attachment_data = Map.put(object.data, "id", object.id) - conn - |> put_status(202) - |> render("attachment.json", %{attachment: attachment_data}) + render(conn, "attachment.json", %{attachment: attachment_data}) end end diff --git a/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs index 4adbaa640..3f696d94d 100644 --- a/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/media_controller_test.exs @@ -56,7 +56,7 @@ defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do conn |> put_req_header("content-type", "multipart/form-data") |> post("/api/v2/media", %{"file" => image, "description" => desc}) - |> json_response_and_validate_schema(202) + |> json_response_and_validate_schema(200) assert media_id = response["id"] @@ -111,7 +111,7 @@ defmodule Pleroma.Web.MastodonAPI.MediaControllerTest do "file" => large_binary, "description" => desc }) - |> json_response_and_validate_schema(202) + |> json_response_and_validate_schema(200) assert media_id = response["id"] From d2de251c4d018c7d517d399d7d5e0e20d853972f Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Tue, 29 Oct 2024 16:00:18 -0400 Subject: [PATCH 178/212] Pleroma.Upload.Filter.Dedupe: sharding directory structure Dedupe now uses a three-level sharding directory structure to improve performance when many files are uploaded and stored on a filesystem instead of an object store. (note: Minio still affected as it still uses a traditional filesystem) This does not help if you already have hundreds of thousands of files uploaded. The media URLs are permanently part of the activity so the files cannot be relocated. A motivated user could write a tool to move the files and perhaps write an Nginx or equivalent redirect to make the files still accessible, but that is beyond the scope of this change. --- changelog.d/dedupe-sharding.change | 1 + lib/pleroma/upload/filter/dedupe.ex | 10 +++++++++- test/pleroma/object_test.exs | 8 ++++---- test/pleroma/upload/filter/dedupe_test.exs | 4 +++- test/pleroma/upload_test.exs | 6 ++++-- 5 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 changelog.d/dedupe-sharding.change diff --git a/changelog.d/dedupe-sharding.change b/changelog.d/dedupe-sharding.change new file mode 100644 index 000000000..2e140d8a2 --- /dev/null +++ b/changelog.d/dedupe-sharding.change @@ -0,0 +1 @@ +Dedupe upload filter now uses a three-level sharding directory structure diff --git a/lib/pleroma/upload/filter/dedupe.ex b/lib/pleroma/upload/filter/dedupe.ex index ef793d390..7b278d299 100644 --- a/lib/pleroma/upload/filter/dedupe.ex +++ b/lib/pleroma/upload/filter/dedupe.ex @@ -17,8 +17,16 @@ defmodule Pleroma.Upload.Filter.Dedupe do |> Base.encode16(case: :lower) filename = shasum <> "." <> extension - {:ok, :filtered, %Upload{upload | id: shasum, path: filename}} + + {:ok, :filtered, %Upload{upload | id: shasum, path: shard_path(filename)}} end def filter(_), do: {:ok, :noop} + + @spec shard_path(String.t()) :: String.t() + def shard_path( + <> = filename + ) do + Path.join([a, b, c, filename]) + end end diff --git a/test/pleroma/object_test.exs b/test/pleroma/object_test.exs index b3c528e32..ed5c2b6c8 100644 --- a/test/pleroma/object_test.exs +++ b/test/pleroma/object_test.exs @@ -174,8 +174,9 @@ defmodule Pleroma.ObjectTest do filename = Path.basename(href) - assert {:ok, files} = File.ls(uploads_dir) - assert filename in files + expected_path = Path.join([uploads_dir, Pleroma.Upload.Filter.Dedupe.shard_path(filename)]) + + assert File.exists?(expected_path) Object.delete(note) @@ -183,8 +184,7 @@ defmodule Pleroma.ObjectTest do assert Object.get_by_id(note.id).data["deleted"] assert Object.get_by_id(attachment.id) == nil - assert {:ok, files} = File.ls(uploads_dir) - refute filename in files + refute File.exists?(expected_path) end test "with objects that have legacy data.url attribute" do diff --git a/test/pleroma/upload/filter/dedupe_test.exs b/test/pleroma/upload/filter/dedupe_test.exs index 29c181509..cd5ce121b 100644 --- a/test/pleroma/upload/filter/dedupe_test.exs +++ b/test/pleroma/upload/filter/dedupe_test.exs @@ -23,10 +23,12 @@ defmodule Pleroma.Upload.Filter.DedupeTest do tempfile: Path.absname("test/fixtures/image_tmp.jpg") } + expected_path = Dedupe.shard_path(@shasum <> ".jpg") + assert { :ok, :filtered, - %Pleroma.Upload{id: @shasum, path: @shasum <> ".jpg"} + %Pleroma.Upload{id: @shasum, path: ^expected_path} } = Dedupe.filter(upload) end end diff --git a/test/pleroma/upload_test.exs b/test/pleroma/upload_test.exs index facb634c3..5fd62fa43 100644 --- a/test/pleroma/upload_test.exs +++ b/test/pleroma/upload_test.exs @@ -149,6 +149,9 @@ defmodule Pleroma.UploadTest do test "copies the file to the configured folder with deduping" do File.cp!("test/fixtures/image.jpg", "test/fixtures/image_tmp.jpg") + expected_filename = "e30397b58d226d6583ab5b8b3c5defb0c682bda5c31ef07a9f57c1c4986e3781.jpg" + + expected_path = Pleroma.Upload.Filter.Dedupe.shard_path(expected_filename) file = %Plug.Upload{ content_type: "image/jpeg", @@ -159,8 +162,7 @@ defmodule Pleroma.UploadTest do {:ok, data} = Upload.store(file, filters: [Pleroma.Upload.Filter.Dedupe]) assert List.first(data["url"])["href"] == - Pleroma.Upload.base_url() <> - "e30397b58d226d6583ab5b8b3c5defb0c682bda5c31ef07a9f57c1c4986e3781.jpg" + Path.join([Pleroma.Upload.base_url(), expected_path]) end test "copies the file to the configured folder without deduping" do From 8d2410948f3310596491fcd21e4726297b89c3ba Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 31 Oct 2024 18:22:21 +0400 Subject: [PATCH 179/212] Mix: Update version --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index 89ec5e831..95fcb7491 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do def project do [ app: :pleroma, - version: version("2.7.0"), + version: version("2.8.0"), elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), compilers: Mix.compilers(), From ebea518c8c9dea17ff18c8fa8192a7957adcfadb Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 12 Nov 2024 12:43:16 +0400 Subject: [PATCH 180/212] B DedupeTest: Add explicit test for the sharding structure --- test/pleroma/upload/filter/dedupe_test.exs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/pleroma/upload/filter/dedupe_test.exs b/test/pleroma/upload/filter/dedupe_test.exs index cd5ce121b..4dc28b998 100644 --- a/test/pleroma/upload/filter/dedupe_test.exs +++ b/test/pleroma/upload/filter/dedupe_test.exs @@ -10,6 +10,10 @@ defmodule Pleroma.Upload.Filter.DedupeTest do @shasum "e30397b58d226d6583ab5b8b3c5defb0c682bda5c31ef07a9f57c1c4986e3781" + test "generates a shard path for a shasum" do + assert "e3/03/97/" <> _path = Dedupe.shard_path(@shasum) + end + test "adds shasum" do File.cp!( "test/fixtures/image.jpg", From 1e9edccab8d2546427f4e02666955740f6e74a60 Mon Sep 17 00:00:00 2001 From: Codimp Date: Sat, 12 Oct 2024 20:46:07 +0000 Subject: [PATCH 181/212] Translated using Weblate (French) Currently translated at 5.4% (53 of 974 strings) Translation: Pleroma/Pleroma Backend (domain config_descriptions) Translate-URL: https://translate.pleroma.social/projects/pleroma/pleroma-backend-domain-config_descriptions/fr/ --- .../fr/LC_MESSAGES/config_descriptions.po | 128 ++++++++++++------ 1 file changed, 85 insertions(+), 43 deletions(-) diff --git a/priv/gettext/fr/LC_MESSAGES/config_descriptions.po b/priv/gettext/fr/LC_MESSAGES/config_descriptions.po index e43db68aa..c24ab6751 100644 --- a/priv/gettext/fr/LC_MESSAGES/config_descriptions.po +++ b/priv/gettext/fr/LC_MESSAGES/config_descriptions.po @@ -3,14 +3,16 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-07-22 02:09+0300\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Automatically generated\n" -"Language-Team: none\n" +"PO-Revision-Date: 2024-10-13 21:03+0000\n" +"Last-Translator: Codimp \n" +"Language-Team: French \n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Translate Toolkit 3.7.2\n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 4.13.1\n" ## This file is a PO Template file. ## @@ -21,7 +23,6 @@ msgstr "" ## Run "mix gettext.extract" to bring this file up to ## date. Leave "msgstr"s empty as changing them here has no ## effect: edit them in PO (.po) files instead. - #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :esshd" @@ -32,25 +33,30 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :logger" msgid "Logger-related settings" -msgstr "" +msgstr "Paramètres liés à la journalisation" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :mime" msgid "Mime Types settings" -msgstr "" +msgstr "Paramètres des types Mime" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma" msgid "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)" msgstr "" +"Permet de configurer un jeton qui peut être utilisé pour authentifier les " +"requêtes avec des privilèges administrateurs sans utiliser un jeton de " +"compte utilisateur standard. Pour l'utiliser, ajoutez le paramètre " +"`admin_token`aux requêtes. (Vous devriez utiliser l'authentification HTTP " +"Basic ou OAuth à la place si vous le pouvez)" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma" msgid "Authenticator" -msgstr "" +msgstr "Authentifieur" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -62,7 +68,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config label at :cors_plug" msgid "CORS plug config" -msgstr "" +msgstr "Configuration du plug CORS" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -74,25 +80,25 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config label at :logger" msgid "Logger" -msgstr "" +msgstr "Journaliseur" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :mime" msgid "Mime Types" -msgstr "" +msgstr "Types Mime" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma" msgid "Pleroma Admin Token" -msgstr "" +msgstr "Jeton Administrateur Pleroma" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config label at :pleroma" msgid "Pleroma Authenticator" -msgstr "" +msgstr "Authentifieur Pleroma" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -104,103 +110,111 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :logger-:console" msgid "Console logger settings" -msgstr "" +msgstr "Paramètres de journalisation de la console" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :logger-:ex_syslogger" msgid "ExSyslogger-related settings" -msgstr "" +msgstr "Paramètres liés à ExSyslogger" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:activitypub" msgid "ActivityPub-related settings" -msgstr "" +msgstr "Paramètres liés à ActivityPub" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:assets" msgid "This section configures assets to be used with various frontends. Currently the only option relates to mascots on the mastodon frontend" msgstr "" +"Cette section configure les annexes (assets) à utiliser avec divers " +"frontaux. La seule option est actuellement liée au mascottes du frontal " +"mastodon" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:auth" msgid "Authentication / authorization settings" -msgstr "" +msgstr "Paramètres d'authentification/autorisations" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:connections_pool" msgid "Advanced settings for `Gun` connections pool" -msgstr "" +msgstr "Paramètres avancés pour le bac (pool) de connexions `Gun`" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:email_notifications" msgid "Email notifications settings" -msgstr "" +msgstr "Paramètres de notification par email" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:features" msgid "Customizable features" -msgstr "" +msgstr "Fonctionnalités personnalisables" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:feed" msgid "Configure feed rendering" -msgstr "" +msgstr "Configurer le rendu des flux" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:frontend_configurations" msgid "This form can be used to configure a keyword list that keeps the configuration data for any kind of frontend. By default, settings for pleroma_fe are configured. If you want to add your own configuration your settings all fields must be complete." msgstr "" +"Ce formulaire peut être utilisé pour configurer une liste de clés (keyword) " +"qui contiennent les données de configuration pour tout types de frontaux. " +"Par défaut, les paramètres pour pleroma_fe sont configurés. Si vous voulez " +"ajouter vos propres paramètres de configurations, tout les champs doivent " +"être remplis." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:frontends" msgid "Installed frontends management" -msgstr "" +msgstr "Gestion des frontaux installés" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:gopher" msgid "Gopher settings" -msgstr "" +msgstr "Paramètres Gopher" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:hackney_pools" msgid "Advanced settings for `Hackney` connections pools" -msgstr "" +msgstr "Paramètres avancés pour les bacs (pool) de connexions `Hackney`" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:http" msgid "HTTP settings" -msgstr "" +msgstr "Paramètres HTTP" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:http_security" msgid "HTTP security settings" -msgstr "" +msgstr "Paramètres de sécurité HTTP" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:instance" msgid "Instance-related settings" -msgstr "" +msgstr "Paramètres liés à l'instance" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:instances_favicons" msgid "Control favicons for instances" -msgstr "" +msgstr "Gère les favicons des instances" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -212,151 +226,177 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:majic_pool" msgid "Majic/libmagic configuration" -msgstr "" +msgstr "Configuration de majic/libmagic" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:manifest" msgid "This section describe PWA manifest instance-specific values. Currently this option relate only for MastoFE." msgstr "" +"Cette section décrit les valeurs spécifique à l'instance du manifeste PWA. " +"Actuellement, cette option ne concerne que MastoFE." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:media_preview_proxy" msgid "Media preview proxy" -msgstr "" +msgstr "Proxy de prévisualisation média" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:media_proxy" msgid "Media proxy" -msgstr "" +msgstr "Proxy média" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:modules" msgid "Custom Runtime Modules" -msgstr "" +msgstr "Modules Runtime Personalisés" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf" msgid "General MRF settings" -msgstr "" +msgstr "Paramètres généraux MRF" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_activity_expiration" msgid "Adds automatic expiration to all local activities" -msgstr "" +msgstr "Ajoute une expiration automatique à toutes les activités locales" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_follow_bot" msgid "Automatically follows newly discovered accounts." -msgstr "" +msgstr "Suivre automatiquement les comptes venant d'être découverts." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_hashtag" msgid "Reject, TWKN-remove or Set-Sensitive messsages with specific hashtags (without the leading #)\n\nNote: This MRF Policy is always enabled, if you want to disable it you have to set empty lists.\n" msgstr "" +"Rejeter, Enlever de TWKN ou marquer comme contenu sensible les messages avec " +"des mots-croisillons (sans mettre le # du début)\n" +"\n" +"Note: cette politique MRF est toujours activée. Si vous voulez la " +"désactiver, vous devez configurer des listes vides.\n" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_hellthread" msgid "Block messages with excessive user mentions" -msgstr "" +msgstr "Bloquer les messages avec un nombre excessif de mentions" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_keyword" msgid "Reject or Word-Replace messages matching a keyword or [Regex](https://hexdocs.pm/elixir/Regex.html)." msgstr "" +"Rejeter ou remplacer les mots des messages qui correspondent à un mot clef " +"ou à une [expression rationnelle (Regex)](https://hexdocs.pm/elixir/Regex." +"html)." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_mention" msgid "Block messages which mention a specific user" -msgstr "" +msgstr "Bloquer les messages mentionnant un utilisateur particulier" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_normalize_markup" msgid "MRF NormalizeMarkup settings. Scrub configured hypertext markup." msgstr "" +"Paramètres de normalisation MRF. Balaie les balises hypertextes configurées." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_object_age" msgid "Rejects or delists posts based on their timestamp deviance from your server's clock." msgstr "" +"Rejette ou retire des listes les messages selon l'écart entre leur heure et " +"l'horloge de votre serveur." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_rejectnonpublic" msgid "RejectNonPublic drops posts with non-public visibility settings." msgstr "" +"RejectNonPublic enlève les messages avec des paramètres de visibilité non-" +"publics." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_simple" msgid "Simple ingress policies" -msgstr "" +msgstr "Politiques simples pour entrants" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_steal_emoji" msgid "Steals emojis from selected instances when it sees them." -msgstr "" +msgstr "Vole les emojis des instances sélectionnées quand il les voit." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_subchain" msgid "This policy processes messages through an alternate pipeline when a given message matches certain criteria. All criteria are configured as a map of regular expressions to lists of policy modules." msgstr "" +"Cette politique traite les messages à travers un tuyau séparé lorsqu'un " +"message donné correspond à certain critères. Chaque critère est configuré " +"comme une correspondance entre une expression rationnelle et une liste de " +"modules de politiques." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:mrf_vocabulary" msgid "Filter messages which belong to certain activity vocabularies" msgstr "" +"Filtrer les messages qui correspondent à certain vocabulaires d'activités" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:oauth2" msgid "Configure OAuth 2 provider capabilities" -msgstr "" +msgstr "Configurer les capacités du fournisseur OAuth 2" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:pools" msgid "Advanced settings for `Gun` workers pools" -msgstr "" +msgstr "Paramètres avancés pour les bacs (pools) de travailleurs `Gun`" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:populate_hashtags_table" msgid "`populate_hashtags_table` background migration settings" -msgstr "" +msgstr "Paramètres de migration en arrière-plan `populate_hashtags_table`" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:rate_limit" msgid "Rate limit settings. This is an advanced feature enabled only for :authentication by default." msgstr "" +"Paramètres de limites par secondes. C'est une fonctionnalité avancée qui, " +"par défaut, n'est activée que pour :authentication." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:restrict_unauthenticated" msgid "Disallow viewing timelines, user profiles and statuses for unauthenticated users." msgstr "" +"Empêche de regarder les flux, les profils utilisateurs et les status pour " +"les utilisateurs non-authentifiés." #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:rich_media" msgid "If enabled the instance will parse metadata from attached links to generate link previews" msgstr "" +"Si activé, l'instance interprétera les métadonnées des liens joins pour " +"générer les prévisualisations de liens" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -369,6 +409,8 @@ msgstr "" msgctxt "config description at :pleroma-:static_fe" msgid "Render profiles and posts using server-generated HTML that is viewable without using JavaScript" msgstr "" +"Rendre les profils et les status en utilisant du HTML généré par le serveur " +"qui ne nécessitera pas de JavaScript" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format @@ -380,7 +422,7 @@ msgstr "" #, elixir-autogen, elixir-format msgctxt "config description at :pleroma-:uri_schemes" msgid "URI schemes related settings" -msgstr "" +msgstr "Paramètres liés au schémas d'URI" #: lib/pleroma/docs/translator.ex:5 #, elixir-autogen, elixir-format From 5b3e4cf49bfc80579c6349dd9f81001142a7d3d0 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 12 Nov 2024 14:22:02 +0400 Subject: [PATCH 182/212] B Providers/ActivityPub: Ensure that nothing explodes on unexpected input. --- lib/pleroma/web/metadata/providers/activity_pub.ex | 3 +++ test/pleroma/web/metadata/providers/activity_pub_test.exs | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/lib/pleroma/web/metadata/providers/activity_pub.ex b/lib/pleroma/web/metadata/providers/activity_pub.ex index 1759a5a0d..bd9f92332 100644 --- a/lib/pleroma/web/metadata/providers/activity_pub.ex +++ b/lib/pleroma/web/metadata/providers/activity_pub.ex @@ -16,4 +16,7 @@ defmodule Pleroma.Web.Metadata.Providers.ActivityPub do def build_tags(%{user: user}) do [{:link, [rel: "alternate", type: "application/activity+json", href: user.ap_id], []}] end + + @impl Provider + def build_tags(_), do: [] end diff --git a/test/pleroma/web/metadata/providers/activity_pub_test.exs b/test/pleroma/web/metadata/providers/activity_pub_test.exs index c379ec092..c5cf78a60 100644 --- a/test/pleroma/web/metadata/providers/activity_pub_test.exs +++ b/test/pleroma/web/metadata/providers/activity_pub_test.exs @@ -31,4 +31,10 @@ defmodule Pleroma.Web.Metadata.Providers.ActivityPubTest do [rel: "alternate", type: "application/activity+json", href: object.data["id"]], []} ] == result end + + test "it returns an empty array for anything else" do + result = ActivityPub.build_tags(%{}) + + assert result == [] + end end From 29b048d351fb9867f11892315bed49adfbb282fb Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 12 Nov 2024 14:35:02 +0400 Subject: [PATCH 183/212] B TwitterAPI/ControllerTest: Actually test the keys --- test/pleroma/web/twitter_api/controller_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pleroma/web/twitter_api/controller_test.exs b/test/pleroma/web/twitter_api/controller_test.exs index 0019b51af..494be9ec7 100644 --- a/test/pleroma/web/twitter_api/controller_test.exs +++ b/test/pleroma/web/twitter_api/controller_test.exs @@ -69,7 +69,7 @@ defmodule Pleroma.Web.TwitterAPI.ControllerTest do |> hd() |> Map.keys() - assert keys -- ["id", "app_name", "valid_until", "scopes"] == [] + assert Enum.sort(keys) == Enum.sort(["id", "app_name", "valid_until", "scopes"]) end test "revoke token", %{token: token} do From 0c3b71e1cc9a60941e434f4fad8d6968c1f00b48 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Wed, 13 Nov 2024 04:24:05 +0100 Subject: [PATCH 184/212] mix.lock: bump fast_html to 2.3.0 --- changelog.d/bump-lexbor.change | 1 + mix.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 changelog.d/bump-lexbor.change diff --git a/changelog.d/bump-lexbor.change b/changelog.d/bump-lexbor.change new file mode 100644 index 000000000..2c7061a81 --- /dev/null +++ b/changelog.d/bump-lexbor.change @@ -0,0 +1 @@ +- Bumped `fast_html` to v2.3.0, which notably allows to use system-installed lexbor with passing `WITH_SYSTEM_LEXBOR=1` environment variable at build-time \ No newline at end of file diff --git a/mix.lock b/mix.lock index 421f99ec0..a98867142 100644 --- a/mix.lock +++ b/mix.lock @@ -50,7 +50,7 @@ "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"}, "exile": {:hex, :exile, "0.10.0", "b69e2d27a9af670b0f0a0898addca0eda78f6f5ba95ccfbc9bc6ccdd04925436", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "c62ee8fee565b5ac4a898d0dcd58d2b04fb5eec1655af1ddcc9eb582c6732c33"}, "expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"}, - "fast_html": {:hex, :fast_html, "2.2.0", "6c5ef1be087a4ed613b0379c13f815c4d11742b36b67bb52cee7859847c84520", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}], "hexpm", "064c4f23b4a6168f9187dac8984b056f2c531bb0787f559fd6a8b34b38aefbae"}, + "fast_html": {:hex, :fast_html, "2.3.0", "08c1d8ead840dd3060ba02c761bed9f37f456a1ddfe30bcdcfee8f651cec06a6", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}], "hexpm", "f18e3c7668f82d3ae0b15f48d48feeb257e28aa5ab1b0dbf781c7312e5da029d"}, "fast_sanitize": {:hex, :fast_sanitize, "0.2.3", "67b93dfb34e302bef49fec3aaab74951e0f0602fd9fa99085987af05bd91c7a5", [:mix], [{:fast_html, "~> 2.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "e8ad286d10d0386e15d67d0ee125245ebcfbc7d7290b08712ba9013c8c5e56e2"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, From c9f9ec04c8b7c24b47acb3f5a00f596b699c2c82 Mon Sep 17 00:00:00 2001 From: Mint Date: Thu, 21 Nov 2024 02:13:10 +0300 Subject: [PATCH 185/212] Meilisearch: use PUT method for indexing Mix task See https://github.com/meilisearch/meilisearch/issues/2619 --- lib/mix/tasks/pleroma/search/meilisearch.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/pleroma/search/meilisearch.ex b/lib/mix/tasks/pleroma/search/meilisearch.ex index 8379a0c25..389b8a564 100644 --- a/lib/mix/tasks/pleroma/search/meilisearch.ex +++ b/lib/mix/tasks/pleroma/search/meilisearch.ex @@ -28,7 +28,7 @@ defmodule Mix.Tasks.Pleroma.Search.Meilisearch do end {:ok, _} = - meili_post( + meili_put( "/indexes/objects/settings/ranking-rules", [ "published:desc", @@ -42,7 +42,7 @@ defmodule Mix.Tasks.Pleroma.Search.Meilisearch do ) {:ok, _} = - meili_post( + meili_put( "/indexes/objects/settings/searchable-attributes", [ "content" From d65f768b59649de5ed5e76d7dd8248c76fd81a9f Mon Sep 17 00:00:00 2001 From: Mint Date: Thu, 21 Nov 2024 02:14:55 +0300 Subject: [PATCH 186/212] Meilisearch: stop attempting to index posts with nil date --- lib/pleroma/search/meilisearch.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pleroma/search/meilisearch.ex b/lib/pleroma/search/meilisearch.ex index 9bba5b30f..cafae8099 100644 --- a/lib/pleroma/search/meilisearch.ex +++ b/lib/pleroma/search/meilisearch.ex @@ -122,6 +122,7 @@ defmodule Pleroma.Search.Meilisearch do # Only index public or unlisted Notes if not is_nil(object) and object.data["type"] == "Note" and not is_nil(object.data["content"]) and + not is_nil(object.data["published"]) and (Pleroma.Constants.as_public() in object.data["to"] or Pleroma.Constants.as_public() in object.data["cc"]) and object.data["content"] not in ["", "."] do From 3a82a51a6e8b25c2e58e75329e12a090ad977519 Mon Sep 17 00:00:00 2001 From: Mint Date: Thu, 21 Nov 2024 02:16:36 +0300 Subject: [PATCH 187/212] Docs: fix OTP mix task command for Meilisearch --- docs/configuration/search.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/configuration/search.md b/docs/configuration/search.md index d34f84d4f..de1a72203 100644 --- a/docs/configuration/search.md +++ b/docs/configuration/search.md @@ -73,7 +73,7 @@ you have to get the _private key_, which is actually used for authentication. === "OTP" ```sh - ./bin/pleroma_ctl search.meilisearch show-keys + ./bin/pleroma_ctl meilisearch show-keys ``` === "From Source" @@ -103,7 +103,7 @@ To start the initial indexing, run the `index` command: === "OTP" ```sh - ./bin/pleroma_ctl search.meilisearch index + ./bin/pleroma_ctl meilisearch index ``` === "From Source" @@ -118,7 +118,7 @@ of indexing and how many posts have actually been indexed, use the `stats` comma === "OTP" ```sh - ./bin/pleroma_ctl search.meilisearch stats + ./bin/pleroma_ctl meilisearch stats ``` === "From Source" @@ -133,7 +133,7 @@ use the `clear` command: === "OTP" ```sh - ./bin/pleroma_ctl search.meilisearch clear + ./bin/pleroma_ctl meilisearch clear ``` === "From Source" From af7de4c17a0f146a3048d5b2742a292e69bbbb70 Mon Sep 17 00:00:00 2001 From: Mint Date: Thu, 21 Nov 2024 02:17:53 +0300 Subject: [PATCH 188/212] Changelog --- changelog.d/meilisearch-misc-fixes.fix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/meilisearch-misc-fixes.fix diff --git a/changelog.d/meilisearch-misc-fixes.fix b/changelog.d/meilisearch-misc-fixes.fix new file mode 100644 index 000000000..0f127d3a8 --- /dev/null +++ b/changelog.d/meilisearch-misc-fixes.fix @@ -0,0 +1 @@ +Miscellaneous fixes for Meilisearch support From da7132caba49777c25413efc8adc90d27576b07f Mon Sep 17 00:00:00 2001 From: Mint Date: Thu, 21 Nov 2024 02:40:27 +0300 Subject: [PATCH 189/212] Remove unused import --- lib/mix/tasks/pleroma/search/meilisearch.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mix/tasks/pleroma/search/meilisearch.ex b/lib/mix/tasks/pleroma/search/meilisearch.ex index 389b8a564..edce9e871 100644 --- a/lib/mix/tasks/pleroma/search/meilisearch.ex +++ b/lib/mix/tasks/pleroma/search/meilisearch.ex @@ -9,7 +9,7 @@ defmodule Mix.Tasks.Pleroma.Search.Meilisearch do import Ecto.Query import Pleroma.Search.Meilisearch, - only: [meili_post: 2, meili_put: 2, meili_get: 1, meili_delete: 1] + only: [meili_put: 2, meili_get: 1, meili_delete: 1] def run(["index"]) do start_pleroma() From 551534f3eeae0062a06e10a322ba17a6d4ee8b9a Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 21 Nov 2024 16:07:09 +0400 Subject: [PATCH 190/212] B ReleaseTasks: Fix task module finding. --- changelog.d/module-search-in-pleroma-ctl.fix | 1 + lib/pleroma/release_tasks.ex | 23 +++++++++++++------- test/pleroma/release_tasks_test.exs | 19 ++++++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 changelog.d/module-search-in-pleroma-ctl.fix create mode 100644 test/pleroma/release_tasks_test.exs diff --git a/changelog.d/module-search-in-pleroma-ctl.fix b/changelog.d/module-search-in-pleroma-ctl.fix new file mode 100644 index 000000000..d32fe3f33 --- /dev/null +++ b/changelog.d/module-search-in-pleroma-ctl.fix @@ -0,0 +1 @@ +Fix pleroma_ctl mix task calls sometimes not being found diff --git a/lib/pleroma/release_tasks.ex b/lib/pleroma/release_tasks.ex index bcfcd1243..af2d35c8f 100644 --- a/lib/pleroma/release_tasks.ex +++ b/lib/pleroma/release_tasks.ex @@ -16,17 +16,24 @@ defmodule Pleroma.ReleaseTasks do end end + def find_module(task) do + module_name = + task + |> String.split(".") + |> Enum.map(&String.capitalize/1) + |> then(fn x -> [Mix, Tasks, Pleroma] ++ x end) + |> Module.concat() + + case Code.ensure_loaded(module_name) do + {:module, _} -> module_name + _ -> nil + end + end + defp mix_task(task, args) do Application.load(:pleroma) - {:ok, modules} = :application.get_key(:pleroma, :modules) - module = - Enum.find(modules, fn module -> - module = Module.split(module) - - match?(["Mix", "Tasks", "Pleroma" | _], module) and - String.downcase(List.last(module)) == task - end) + module = find_module(task) if module do module.run(args) diff --git a/test/pleroma/release_tasks_test.exs b/test/pleroma/release_tasks_test.exs new file mode 100644 index 000000000..5a4293189 --- /dev/null +++ b/test/pleroma/release_tasks_test.exs @@ -0,0 +1,19 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.ReleaseTaskTest do + use Pleroma.DataCase, async: true + + alias Pleroma.ReleaseTasks + + test "finding the module" do + task = "search.meilisearch" + assert Mix.Tasks.Pleroma.Search.Meilisearch == ReleaseTasks.find_module(task) + + task = "user" + assert Mix.Tasks.Pleroma.User == ReleaseTasks.find_module(task) + + refute ReleaseTasks.find_module("doesnt.exist") + end +end From 14dbf789b3e0e84f588999954f07a378a6ccfcf6 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 21 Nov 2024 16:32:05 +0400 Subject: [PATCH 191/212] Linting --- test/pleroma/{release_tasks_test.exs => release_task_test.exs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/pleroma/{release_tasks_test.exs => release_task_test.exs} (100%) diff --git a/test/pleroma/release_tasks_test.exs b/test/pleroma/release_task_test.exs similarity index 100% rename from test/pleroma/release_tasks_test.exs rename to test/pleroma/release_task_test.exs From 462a6a2000d10e3b6047bc72143ff239caf40186 Mon Sep 17 00:00:00 2001 From: Mint Date: Thu, 21 Nov 2024 16:52:30 +0300 Subject: [PATCH 192/212] Revert "Docs: fix OTP mix task command for Meilisearch" This reverts commit 3a82a51a6e8b25c2e58e75329e12a090ad977519. --- docs/configuration/search.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/configuration/search.md b/docs/configuration/search.md index de1a72203..d34f84d4f 100644 --- a/docs/configuration/search.md +++ b/docs/configuration/search.md @@ -73,7 +73,7 @@ you have to get the _private key_, which is actually used for authentication. === "OTP" ```sh - ./bin/pleroma_ctl meilisearch show-keys + ./bin/pleroma_ctl search.meilisearch show-keys ``` === "From Source" @@ -103,7 +103,7 @@ To start the initial indexing, run the `index` command: === "OTP" ```sh - ./bin/pleroma_ctl meilisearch index + ./bin/pleroma_ctl search.meilisearch index ``` === "From Source" @@ -118,7 +118,7 @@ of indexing and how many posts have actually been indexed, use the `stats` comma === "OTP" ```sh - ./bin/pleroma_ctl meilisearch stats + ./bin/pleroma_ctl search.meilisearch stats ``` === "From Source" @@ -133,7 +133,7 @@ use the `clear` command: === "OTP" ```sh - ./bin/pleroma_ctl meilisearch clear + ./bin/pleroma_ctl search.meilisearch clear ``` === "From Source" From ced6b10c70769e39ee7d3b6a3fe63b8c2aea3ec0 Mon Sep 17 00:00:00 2001 From: feld Date: Mon, 12 Aug 2024 19:52:37 +0000 Subject: [PATCH 193/212] Merge branch 'swoosh-mailgun' into 'develop' Fix Swoosh Mailgun support See merge request pleroma/pleroma!4217 --- changelog.d/mailgun.fix | 1 + mix.exs | 1 + mix.lock | 1 + 3 files changed, 3 insertions(+) create mode 100644 changelog.d/mailgun.fix diff --git a/changelog.d/mailgun.fix b/changelog.d/mailgun.fix new file mode 100644 index 000000000..855588752 --- /dev/null +++ b/changelog.d/mailgun.fix @@ -0,0 +1 @@ +The Swoosh email adapter for Mailgun was missing a new dependency on :multipart diff --git a/mix.exs b/mix.exs index 69e52e526..e3c8559ba 100644 --- a/mix.exs +++ b/mix.exs @@ -202,6 +202,7 @@ defmodule Pleroma.Mixfile do {:bandit, "~> 1.5.2"}, {:websock_adapter, "~> 0.5.6"}, {:oban_live_dashboard, "~> 0.1.1"}, + {:multipart, "~> 0.4.0", optional: true}, ## dev & test {:phoenix_live_reload, "~> 1.3.3", only: :dev}, diff --git a/mix.lock b/mix.lock index 61ede9e5e..37ac1768b 100644 --- a/mix.lock +++ b/mix.lock @@ -84,6 +84,7 @@ "mock": {:hex, :mock, "0.3.8", "7046a306b71db2488ef54395eeb74df0a7f335a7caca4a3d3875d1fc81c884dd", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "7fa82364c97617d79bb7d15571193fc0c4fe5afd0c932cef09426b3ee6fe2022"}, "mogrify": {:hex, :mogrify, "0.8.0", "3506f3ca3f7b95a155f3b4ef803b5db176f5a0633723e3fe85e0d6399e3b11c8", [:mix], [], "hexpm", "2278d245f07056ea3b586e98801e933695147066fa4cf563f552c1b4f0ff8ad9"}, "mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"}, + "multipart": {:hex, :multipart, "0.4.0", "634880a2148d4555d050963373d0e3bbb44a55b2badd87fa8623166172e9cda0", [:mix], [{:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm", "3c5604bc2fb17b3137e5d2abdf5dacc2647e60c5cc6634b102cf1aef75a06f0a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, "nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"}, From 2977779e94cb34610d0c2369ecacf7d721ebc5ab Mon Sep 17 00:00:00 2001 From: feld Date: Fri, 6 Sep 2024 16:30:07 +0000 Subject: [PATCH 194/212] Merge branch 'well-known' into 'develop' NodeInfo: Accept application/activity+json requests See merge request pleroma/pleroma!4242 --- changelog.d/well-known.change | 1 + lib/pleroma/web/router.ex | 2 +- test/pleroma/web/node_info_test.exs | 13 +++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 changelog.d/well-known.change diff --git a/changelog.d/well-known.change b/changelog.d/well-known.change new file mode 100644 index 000000000..e928124fb --- /dev/null +++ b/changelog.d/well-known.change @@ -0,0 +1 @@ +Accept application/activity+json for requests to .well-known/nodeinfo diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index fc40a1143..4294dffa5 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -189,7 +189,7 @@ defmodule Pleroma.Web.Router do end pipeline :well_known do - plug(:accepts, ["json", "jrd", "jrd+json", "xml", "xrd+xml"]) + plug(:accepts, ["activity+json", "json", "jrd", "jrd+json", "xml", "xrd+xml"]) end pipeline :config do diff --git a/test/pleroma/web/node_info_test.exs b/test/pleroma/web/node_info_test.exs index f474220be..afe4ebb36 100644 --- a/test/pleroma/web/node_info_test.exs +++ b/test/pleroma/web/node_info_test.exs @@ -24,6 +24,19 @@ defmodule Pleroma.Web.NodeInfoTest do |> get(href) |> json_response(200) end) + + accept_types = [ + "application/activity+json", + "application/json", + "application/jrd+json" + ] + + for type <- accept_types do + conn + |> put_req_header("accept", type) + |> get("/.well-known/nodeinfo") + |> json_response(200) + end end test "nodeinfo shows staff accounts", %{conn: conn} do From a6e97c497b5ac418d9825200542d4d4d273f91f7 Mon Sep 17 00:00:00 2001 From: feld Date: Fri, 6 Sep 2024 13:27:06 +0000 Subject: [PATCH 195/212] Merge branch 'following-state-bug' into 'develop' Fix Following status bug See merge request pleroma/pleroma!4251 --- changelog.d/following-state.fix | 1 + .../web/mastodon_api/views/account_view.ex | 19 +++++---- mix.lock | 2 +- .../mastodon_api/views/account_view_test.exs | 39 +++++++++++++++++++ 4 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 changelog.d/following-state.fix diff --git a/changelog.d/following-state.fix b/changelog.d/following-state.fix new file mode 100644 index 000000000..314ea6210 --- /dev/null +++ b/changelog.d/following-state.fix @@ -0,0 +1 @@ +Resolved edge case where the API can report you are following a user but the relationship is not fully established. diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 6976ca6e5..298c73986 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -92,14 +92,13 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do User.get_follow_state(reading_user, target) end - followed_by = - if following_relationships do - case FollowingRelationship.find(following_relationships, target, reading_user) do - %{state: :follow_accept} -> true - _ -> false - end - else - User.following?(target, reading_user) + followed_by = FollowingRelationship.following?(target, reading_user) + following = FollowingRelationship.following?(reading_user, target) + + requested = + cond do + following -> false + true -> match?(:follow_pending, follow_state) end subscribing = @@ -114,7 +113,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do # NOTE: adjust UserRelationship.view_relationships_option/2 on new relation-related flags %{ id: to_string(target.id), - following: follow_state == :follow_accept, + following: following, followed_by: followed_by, blocking: UserRelationship.exists?( @@ -150,7 +149,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do ), subscribing: subscribing, notifying: subscribing, - requested: follow_state == :follow_pending, + requested: requested, domain_blocking: User.blocks_domain?(reading_user, target), showing_reblogs: not UserRelationship.exists?( diff --git a/mix.lock b/mix.lock index 37ac1768b..09db91ffe 100644 --- a/mix.lock +++ b/mix.lock @@ -22,7 +22,7 @@ "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, - "credo": {:hex, :credo, "1.7.3", "05bb11eaf2f2b8db370ecaa6a6bda2ec49b2acd5e0418bc106b73b07128c0436", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "35ea675a094c934c22fb1dca3696f3c31f2728ae6ef5a53b5d648c11180a4535"}, + "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "custom_base": {:hex, :custom_base, "0.2.1", "4a832a42ea0552299d81652aa0b1f775d462175293e99dfbe4d7dbaab785a706", [:mix], [], "hexpm", "8df019facc5ec9603e94f7270f1ac73ddf339f56ade76a721eaa57c1493ba463"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 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 f0711fa0d..73ec67a3d 100644 --- a/test/pleroma/web/mastodon_api/views/account_view_test.exs +++ b/test/pleroma/web/mastodon_api/views/account_view_test.exs @@ -456,6 +456,45 @@ defmodule Pleroma.Web.MastodonAPI.AccountViewTest do test_relationship_rendering(user, other_user, expected) end + test "relationship does not indicate following if a FollowingRelationship is missing" do + user = insert(:user) + other_user = insert(:user, local: false) + + # Create a follow relationship with the real Follow Activity and Accept it + assert {:ok, _, _, _} = CommonAPI.follow(other_user, user) + assert {:ok, _} = CommonAPI.accept_follow_request(user, other_user) + + assert %{data: %{"state" => "accept"}} = + Pleroma.Web.ActivityPub.Utils.fetch_latest_follow(user, other_user) + + # Fetch the relationship and forcibly delete it to simulate + # a Follow Accept that did not complete processing + %{following_relationships: [relationship]} = + Pleroma.UserRelationship.view_relationships_option(user, [other_user]) + + assert {:ok, _} = Pleroma.Repo.delete(relationship) + + assert %{following_relationships: [], user_relationships: []} == + Pleroma.UserRelationship.view_relationships_option(user, [other_user]) + + expected = + Map.merge( + @blank_response, + %{ + following: false, + followed_by: false, + muting: false, + muting_notifications: false, + subscribing: false, + notifying: false, + showing_reblogs: true, + 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 user = insert(:user) other_user = insert(:user) From f45f17b5ff4e0bc454d9340c500e8b923c9e57cb Mon Sep 17 00:00:00 2001 From: lain Date: Thu, 8 Aug 2024 05:29:46 +0000 Subject: [PATCH 196/212] Merge branch 'follow-validator' into 'develop' Do not require a cc field when validating an incoming Follow activity See merge request pleroma/pleroma!4212 --- changelog.d/follow-validator.fix | 1 + .../object_validators/accept_reject_validator.ex | 2 +- .../activity_pub/object_validators/block_validator.ex | 2 +- .../activity_pub/object_validators/follow_validator.ex | 2 +- .../object_validators/follow_validation_test.exs | 10 ++++++++++ 5 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 changelog.d/follow-validator.fix diff --git a/changelog.d/follow-validator.fix b/changelog.d/follow-validator.fix new file mode 100644 index 000000000..d49932b7b --- /dev/null +++ b/changelog.d/follow-validator.fix @@ -0,0 +1 @@ +Improve the FollowValidator to successfully incoming activities with an errant cc field. 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 index d611da051..03ab83347 100644 --- a/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/accept_reject_validator.ex @@ -29,7 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AcceptRejectValidator do defp validate_data(cng) do cng - |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_required([:id, :type, :actor, :to, :object]) |> validate_inclusion(:type, ["Accept", "Reject"]) |> validate_actor_presence() |> validate_object_presence(allowed_types: ["Follow"]) 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 0de87a27e..98340545c 100644 --- a/lib/pleroma/web/activity_pub/object_validators/block_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/block_validator.ex @@ -29,7 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.BlockValidator do defp validate_data(cng) do cng - |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_required([:id, :type, :actor, :to, :object]) |> validate_inclusion(:type, ["Block"]) |> CommonValidations.validate_actor_presence() |> CommonValidations.validate_actor_presence(field_name: :object) 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 b3ca5b691..e4e97bf72 100644 --- a/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/follow_validator.ex @@ -29,7 +29,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.FollowValidator do defp validate_data(cng) do cng - |> validate_required([:id, :type, :actor, :to, :cc, :object]) + |> validate_required([:id, :type, :actor, :to, :object]) |> validate_inclusion(:type, ["Follow"]) |> validate_inclusion(:state, ~w{pending reject accept}) |> validate_actor_presence() diff --git a/test/pleroma/web/activity_pub/object_validators/follow_validation_test.exs b/test/pleroma/web/activity_pub/object_validators/follow_validation_test.exs index 371368e0e..acf6e8d8f 100644 --- a/test/pleroma/web/activity_pub/object_validators/follow_validation_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/follow_validation_test.exs @@ -22,5 +22,15 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.FollowValidationTest do test "validates a basic follow object", %{valid_follow: valid_follow} do assert {:ok, _follow, []} = ObjectValidator.validate(valid_follow, []) end + + test "supports a nil cc", %{valid_follow: valid_follow} do + valid_follow_with_nil_cc = Map.put(valid_follow, "cc", nil) + assert {:ok, _follow, []} = ObjectValidator.validate(valid_follow_with_nil_cc, []) + end + + test "supports an empty cc", %{valid_follow: valid_follow} do + valid_follow_with_empty_cc = Map.put(valid_follow, "cc", []) + assert {:ok, _follow, []} = ObjectValidator.validate(valid_follow_with_empty_cc, []) + end end end From 53c2d2cd87d35094b0ae623b8daab4d303ed5fcb Mon Sep 17 00:00:00 2001 From: lain Date: Wed, 13 Nov 2024 08:22:44 +0000 Subject: [PATCH 197/212] Merge branch 'mastodon-websocket-fix' into 'develop' Fix Mastodon WebSocket authentication See merge request pleroma/pleroma!4206 --- changelog.d/mastodon-websocket.fix | 1 + lib/pleroma/web/endpoint.ex | 1 + lib/pleroma/web/mastodon_api/websocket_handler.ex | 11 ++++++++++- mix.exs | 3 ++- mix.lock | 2 +- test/pleroma/integration/mastodon_websocket_test.exs | 11 +++++++++++ 6 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 changelog.d/mastodon-websocket.fix diff --git a/changelog.d/mastodon-websocket.fix b/changelog.d/mastodon-websocket.fix new file mode 100644 index 000000000..2c4fe86ef --- /dev/null +++ b/changelog.d/mastodon-websocket.fix @@ -0,0 +1 @@ +Fix Mastodon WebSocket authentication diff --git a/lib/pleroma/web/endpoint.ex b/lib/pleroma/web/endpoint.ex index fef907ace..bab3c9fd0 100644 --- a/lib/pleroma/web/endpoint.ex +++ b/lib/pleroma/web/endpoint.ex @@ -14,6 +14,7 @@ defmodule Pleroma.Web.Endpoint do websocket: [ path: "/", compress: false, + connect_info: [:sec_websocket_protocol], error_handler: {Pleroma.Web.MastodonAPI.WebsocketHandler, :handle_error, []}, fullsweep_after: 20 ] diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index 730295a4c..3ed1cdd6c 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -22,7 +22,7 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do # This only prepares the connection and is not in the process yet @impl Phoenix.Socket.Transport def connect(%{params: params} = transport_info) do - with access_token <- Map.get(params, "access_token"), + with access_token <- find_access_token(transport_info), {:ok, user, oauth_token} <- authenticate_request(access_token), {:ok, topic} <- Streamer.get_topic(params["stream"], user, oauth_token, params) do @@ -244,4 +244,13 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do def handle_error(conn, _reason) do Plug.Conn.send_resp(conn, 404, "Not Found") end + + defp find_access_token(%{ + connect_info: %{sec_websocket_protocol: [token]} + }), + do: token + + defp find_access_token(%{params: %{"access_token" => token}}), do: token + + defp find_access_token(_), do: nil end diff --git a/mix.exs b/mix.exs index e3c8559ba..88140c69b 100644 --- a/mix.exs +++ b/mix.exs @@ -132,7 +132,8 @@ defmodule Pleroma.Mixfile do # Type `mix help deps` for examples and options. defp deps do [ - {:phoenix, "~> 1.7.3"}, + {:phoenix, + git: "https://github.com/feld/phoenix", branch: "v1.7.14-websocket-headers", override: true}, {:phoenix_ecto, "~> 4.4"}, {:ecto_sql, "~> 3.10"}, {:ecto_enum, "~> 1.4"}, diff --git a/mix.lock b/mix.lock index 09db91ffe..4d0b9003c 100644 --- a/mix.lock +++ b/mix.lock @@ -95,7 +95,7 @@ "open_api_spex": {:hex, :open_api_spex, "3.18.2", "8c855e83bfe8bf81603d919d6e892541eafece3720f34d1700b58024dadde247", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "aa3e6dcfc0ad6a02596b2172662da21c9dd848dac145ea9e603f54e3d81b8d2b"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "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.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, + "phoenix": {:git, "https://github.com/feld/phoenix", "fb6dc76c657422e49600896c64aab4253fceaef6", [branch: "v1.7.14-websocket-headers"]}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.3", "86e9878f833829c3f66da03d75254c155d91d72a201eb56ae83482328dc7ca93", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d36c401206f3011fefd63d04e8ef626ec8791975d9d107f9a0817d426f61ac07"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"}, diff --git a/test/pleroma/integration/mastodon_websocket_test.exs b/test/pleroma/integration/mastodon_websocket_test.exs index f499f54ad..88f32762d 100644 --- a/test/pleroma/integration/mastodon_websocket_test.exs +++ b/test/pleroma/integration/mastodon_websocket_test.exs @@ -268,6 +268,17 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do end) end + test "accepts valid token on Sec-WebSocket-Protocol header", %{token: token} do + assert {:ok, _} = start_socket("?stream=user", [{"Sec-WebSocket-Protocol", token.token}]) + + capture_log(fn -> + assert {:error, %WebSockex.RequestError{code: 401}} = + start_socket("?stream=user", [{"Sec-WebSocket-Protocol", "I am a friend"}]) + + Process.sleep(30) + end) + end + test "accepts valid token on client-sent event", %{token: token} do assert {:ok, pid} = start_socket() From 6a0883e5d33bd03fb3d71ad3a3560adc5ce6d626 Mon Sep 17 00:00:00 2001 From: feld Date: Fri, 16 Aug 2024 00:37:10 +0000 Subject: [PATCH 198/212] Merge branch 'bugfix-truncate-remote-user-fields' into 'develop' User: truncate remote user fields instead of rejecting See merge request pleroma/pleroma!4220 --- .../bugfix-truncate-remote-user-fields.fix | 1 + lib/pleroma/user.ex | 2 ++ test/pleroma/user_test.exs | 15 +++++++++++++++ .../transmogrifier/user_update_handling_test.exs | 4 ++-- 4 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 changelog.d/bugfix-truncate-remote-user-fields.fix diff --git a/changelog.d/bugfix-truncate-remote-user-fields.fix b/changelog.d/bugfix-truncate-remote-user-fields.fix new file mode 100644 index 000000000..239a3c224 --- /dev/null +++ b/changelog.d/bugfix-truncate-remote-user-fields.fix @@ -0,0 +1 @@ +Truncate remote user fields, avoids them getting rejected diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index e28d76a7c..4f6cdc03e 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -463,6 +463,7 @@ defmodule Pleroma.User do def remote_user_changeset(struct \\ %User{local: false}, params) do bio_limit = Config.get([:instance, :user_bio_length], 5000) name_limit = Config.get([:instance, :user_name_length], 100) + fields_limit = Config.get([:instance, :max_remote_account_fields], 0) name = case params[:name] do @@ -476,6 +477,7 @@ defmodule Pleroma.User do |> Map.put_new(:last_refreshed_at, NaiveDateTime.utc_now()) |> truncate_if_exists(:name, name_limit) |> truncate_if_exists(:bio, bio_limit) + |> Map.update(:fields, [], &Enum.take(&1, fields_limit)) |> truncate_fields_param() |> fix_follower_address() diff --git a/test/pleroma/user_test.exs b/test/pleroma/user_test.exs index 036ae78fb..06afc0709 100644 --- a/test/pleroma/user_test.exs +++ b/test/pleroma/user_test.exs @@ -1075,6 +1075,21 @@ defmodule Pleroma.UserTest do refute cs.valid? end + + test "it truncates fields" do + clear_config([:instance, :max_remote_account_fields], 2) + + fields = [ + %{"name" => "One", "value" => "Uno"}, + %{"name" => "Two", "value" => "Dos"}, + %{"name" => "Three", "value" => "Tres"} + ] + + cs = User.remote_user_changeset(@valid_remote |> Map.put(:fields, fields)) + + assert [%{"name" => "One", "value" => "Uno"}, %{"name" => "Two", "value" => "Dos"}] == + Ecto.Changeset.get_field(cs, :fields) + end end describe "followers and friends" do diff --git a/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs b/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs index da46f063a..851c60850 100644 --- a/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs +++ b/test/pleroma/web/activity_pub/transmogrifier/user_update_handling_test.exs @@ -119,8 +119,8 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier.UserUpdateHandlingTest do user = User.get_cached_by_ap_id(user.ap_id) assert user.fields == [ - %{"name" => "foo", "value" => "updated"}, - %{"name" => "foo1", "value" => "updated"} + %{"name" => "foo", "value" => "bar"}, + %{"name" => "foo11", "value" => "bar11"} ] update_data = From 7bb2dccc058cd1659ab21942b06657abf9127365 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Tue, 26 Nov 2024 14:09:09 +0100 Subject: [PATCH 199/212] Version 2.7.1 --- CHANGELOG.md | 12 ++++++++++++ changelog.d/bugfix-truncate-remote-user-fields.fix | 1 - changelog.d/follow-validator.fix | 1 - changelog.d/following-state.fix | 1 - changelog.d/mailgun.fix | 1 - changelog.d/mastodon-websocket.fix | 1 - changelog.d/well-known.change | 1 - mix.exs | 2 +- 8 files changed, 13 insertions(+), 7 deletions(-) delete mode 100644 changelog.d/bugfix-truncate-remote-user-fields.fix delete mode 100644 changelog.d/follow-validator.fix delete mode 100644 changelog.d/following-state.fix delete mode 100644 changelog.d/mailgun.fix delete mode 100644 changelog.d/mastodon-websocket.fix delete mode 100644 changelog.d/well-known.change diff --git a/CHANGELOG.md b/CHANGELOG.md index 61bb2ab54..424a9afbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ 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/). +## 2.7.1 + +### Changed +- Accept `application/activity+json` for requests to `/.well-known/nodeinfo` + +### Fixed +- Truncate remote user fields, avoids them getting rejected +- Improve the `FollowValidator` to successfully incoming activities with an errant `cc` field. +- Resolved edge case where the API can report you are following a user but the relationship is not fully established. +- The Swoosh email adapter for Mailgun was missing a new dependency on `:multipart` +- Fix Mastodon WebSocket authentication + ## 2.7.0 ### Security diff --git a/changelog.d/bugfix-truncate-remote-user-fields.fix b/changelog.d/bugfix-truncate-remote-user-fields.fix deleted file mode 100644 index 239a3c224..000000000 --- a/changelog.d/bugfix-truncate-remote-user-fields.fix +++ /dev/null @@ -1 +0,0 @@ -Truncate remote user fields, avoids them getting rejected diff --git a/changelog.d/follow-validator.fix b/changelog.d/follow-validator.fix deleted file mode 100644 index d49932b7b..000000000 --- a/changelog.d/follow-validator.fix +++ /dev/null @@ -1 +0,0 @@ -Improve the FollowValidator to successfully incoming activities with an errant cc field. diff --git a/changelog.d/following-state.fix b/changelog.d/following-state.fix deleted file mode 100644 index 314ea6210..000000000 --- a/changelog.d/following-state.fix +++ /dev/null @@ -1 +0,0 @@ -Resolved edge case where the API can report you are following a user but the relationship is not fully established. diff --git a/changelog.d/mailgun.fix b/changelog.d/mailgun.fix deleted file mode 100644 index 855588752..000000000 --- a/changelog.d/mailgun.fix +++ /dev/null @@ -1 +0,0 @@ -The Swoosh email adapter for Mailgun was missing a new dependency on :multipart diff --git a/changelog.d/mastodon-websocket.fix b/changelog.d/mastodon-websocket.fix deleted file mode 100644 index 2c4fe86ef..000000000 --- a/changelog.d/mastodon-websocket.fix +++ /dev/null @@ -1 +0,0 @@ -Fix Mastodon WebSocket authentication diff --git a/changelog.d/well-known.change b/changelog.d/well-known.change deleted file mode 100644 index e928124fb..000000000 --- a/changelog.d/well-known.change +++ /dev/null @@ -1 +0,0 @@ -Accept application/activity+json for requests to .well-known/nodeinfo diff --git a/mix.exs b/mix.exs index 88140c69b..2448b0c66 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Pleroma.Mixfile do def project do [ app: :pleroma, - version: version("2.7.0"), + version: version("2.7.1"), elixir: "~> 1.13", elixirc_paths: elixirc_paths(Mix.env()), compilers: Mix.compilers(), From 3f98c8bd1b86a87e204879da4172178173050638 Mon Sep 17 00:00:00 2001 From: kPherox Date: Wed, 27 Nov 2024 15:45:14 +0900 Subject: [PATCH 200/212] fix: skip directory entries In OTP 27.1 or later, `:zip.unzip/2` without `:skip_directories` option returns directory entries. However in OTP 26, passing `:skip_directories` returns a `:bad_option` error, so this option is not available for compatibility. --- lib/pleroma/frontend.ex | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/pleroma/frontend.ex b/lib/pleroma/frontend.ex index 816499917..a4f427ae5 100644 --- a/lib/pleroma/frontend.ex +++ b/lib/pleroma/frontend.ex @@ -74,11 +74,14 @@ defmodule Pleroma.Frontend do new_file_path = Path.join(dest, path) - new_file_path + path |> Path.dirname() + |> then(&Path.join(dest, &1)) |> File.mkdir_p!() - File.write!(new_file_path, data) + if not File.dir?(new_file_path) do + File.write!(new_file_path, data) + end end) end end From b51f5a84eb7e2f3acb2d7fed54213a9680983bce Mon Sep 17 00:00:00 2001 From: tusooa Date: Tue, 15 Oct 2024 20:03:20 -0400 Subject: [PATCH 201/212] Verify a local Update sent through AP C2S so users can only update their own objects --- changelog.d/c2s-update-verify.fix | 1 + .../activity_pub/activity_pub_controller.ex | 2 +- .../web/activity_pub/object_validator.ex | 13 ++++-- .../object_validators/update_validator.ex | 43 ++++++++++++++++--- .../activity_pub_controller_test.exs | 22 ++++++++++ 5 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 changelog.d/c2s-update-verify.fix diff --git a/changelog.d/c2s-update-verify.fix b/changelog.d/c2s-update-verify.fix new file mode 100644 index 000000000..a4dfe7c07 --- /dev/null +++ b/changelog.d/c2s-update-verify.fix @@ -0,0 +1 @@ +Verify a local Update sent through AP C2S so users can only update their own objects diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index a08eda5f4..7ac0bbab4 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -482,7 +482,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do |> put_status(:forbidden) |> json(message) - {:error, message} -> + {:error, message} when is_binary(message) -> conn |> put_status(:bad_request) |> json(message) diff --git a/lib/pleroma/web/activity_pub/object_validator.ex b/lib/pleroma/web/activity_pub/object_validator.ex index 35774d410..c509890f6 100644 --- a/lib/pleroma/web/activity_pub/object_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validator.ex @@ -169,7 +169,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do meta = Keyword.put(meta, :object_data, object_data), {:ok, update_activity} <- update_activity - |> UpdateValidator.cast_and_validate() + |> UpdateValidator.cast_and_validate(meta) |> Ecto.Changeset.apply_action(:insert) do update_activity = stringify_keys(update_activity) {:ok, update_activity, meta} @@ -177,7 +177,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do {:local, _} -> with {:ok, object} <- update_activity - |> UpdateValidator.cast_and_validate() + |> UpdateValidator.cast_and_validate(meta) |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} @@ -207,9 +207,16 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do "Answer" -> AnswerValidator end + cast_func = + if type == "Update" do + fn o -> validator.cast_and_validate(o, meta) end + else + fn o -> validator.cast_and_validate(o) end + end + with {:ok, object} <- object - |> validator.cast_and_validate() + |> cast_func.() |> Ecto.Changeset.apply_action(:insert) do object = stringify_keys(object) {:ok, object, meta} 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 1e940a400..aab90235f 100644 --- a/lib/pleroma/web/activity_pub/object_validators/update_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/update_validator.ex @@ -6,6 +6,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator do use Ecto.Schema alias Pleroma.EctoType.ActivityPub.ObjectValidators + alias Pleroma.Object + alias Pleroma.User import Ecto.Changeset import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations @@ -31,23 +33,50 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.UpdateValidator do |> cast(data, __schema__(:fields)) end - defp validate_data(cng) do + defp validate_data(cng, meta) do cng |> validate_required([:id, :type, :actor, :to, :cc, :object]) |> validate_inclusion(:type, ["Update"]) |> validate_actor_presence() - |> validate_updating_rights() + |> validate_updating_rights(meta) end - def cast_and_validate(data) do + def cast_and_validate(data, meta \\ []) do data |> cast_data - |> validate_data + |> validate_data(meta) end - # For now we only support updating users, and here the rule is easy: - # object id == actor id - def validate_updating_rights(cng) do + def validate_updating_rights(cng, meta) do + if meta[:local] do + validate_updating_rights_local(cng) + else + validate_updating_rights_remote(cng) + end + end + + # For local Updates, verify the actor can edit the object + def validate_updating_rights_local(cng) do + actor = get_field(cng, :actor) + updated_object = get_field(cng, :object) + + if {:ok, actor} == ObjectValidators.ObjectID.cast(updated_object) do + cng + else + with %User{} = user <- User.get_cached_by_ap_id(actor), + {_, %Object{} = orig_object} <- {:object, Object.normalize(updated_object)}, + :ok <- Object.authorize_access(orig_object, user) do + cng + else + _e -> + cng + |> add_error(:object, "Can't be updated by this actor") + end + end + end + + # For remote Updates, verify the host is the same. + def validate_updating_rights_remote(cng) do with actor = get_field(cng, :actor), object = get_field(cng, :object), {:ok, object_id} <- ObjectValidators.ObjectID.cast(object), diff --git a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs index d4175b56f..b627478dc 100644 --- a/test/pleroma/web/activity_pub/activity_pub_controller_test.exs +++ b/test/pleroma/web/activity_pub/activity_pub_controller_test.exs @@ -1644,6 +1644,28 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do assert json_response(conn, 403) end + test "it rejects update activity of object from other actor", %{conn: conn} do + note_activity = insert(:note_activity) + note_object = Object.normalize(note_activity, fetch: false) + user = insert(:user) + + data = %{ + type: "Update", + object: %{ + id: note_object.data["id"] + } + } + + conn = + conn + |> assign(:user, user) + |> put_req_header("content-type", "application/activity+json") + |> post("/users/#{user.nickname}/outbox", data) + + assert json_response(conn, 400) + assert note_object == Object.normalize(note_activity, fetch: false) + end + test "it increases like count when receiving a like action", %{conn: conn} do note_activity = insert(:note_activity) note_object = Object.normalize(note_activity, fetch: false) From c0fdd0e2cf2e57aa02776c41d21b10c17f745193 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Mon, 9 Dec 2024 12:48:11 +0400 Subject: [PATCH 202/212] Update changelog --- CHANGELOG.md | 59 +++++++++++++++++++ changelog.d/activity-pub-metadata.add | 1 - changelog.d/argon2-passwords.add | 1 - changelog.d/atom-tag.change | 1 - changelog.d/bump-lexbor.change | 1 - changelog.d/c2s-update-verify.fix | 1 - changelog.d/ci-git-fetch.skip | 0 changelog.d/commonapi.skip | 0 changelog.d/debian-install-improve.skip | 1 - changelog.d/dedupe-sharding.change | 1 - changelog.d/deprecate-subscribe.change | 1 - changelog.d/dialyzer.skip | 0 changelog.d/docs-fix.skip | 0 changelog.d/docs-vips.skip | 0 changelog.d/drop-unwanted.change | 1 - changelog.d/elixir-1.14-docker.skip | 0 changelog.d/elixir.change | 1 - changelog.d/follow-request.fix | 1 - changelog.d/freebsd-docs.skip | 0 changelog.d/get-statuses-param.change | 1 - changelog.d/hashtag-feeds-restricted.add | 1 - changelog.d/identity-proofs.remove | 1 - changelog.d/incoming-blocks.fix | 1 - changelog.d/ldap-ca.add | 1 - changelog.d/ldap-password-change.add | 1 - changelog.d/ldap-refactor.change | 1 - changelog.d/ldap-tls.fix | 1 - changelog.d/ldap-warning.skip | 0 changelog.d/ldaps.fix | 1 - changelog.d/list-id-visibility.add | 1 - changelog.d/manifest-icon-size.skip | 0 changelog.d/mediav2_status.fix | 1 - changelog.d/meilisearch-misc-fixes.fix | 1 - changelog.d/module-search-in-pleroma-ctl.fix | 1 - changelog.d/mogrify.skip | 0 changelog.d/mrf-cleanup.skip | 0 changelog.d/mrf-fodirectreply.add | 1 - changelog.d/mrf-id_filter.add | 1 - changelog.d/mrf-quietreply.add | 1 - changelog.d/notifications-group-key.add | 1 - changelog.d/notifications-marker.change | 1 - changelog.d/oauth-app-spam.fix | 1 - changelog.d/oban-recevier-improvements.fix | 1 - changelog.d/oban-uniques.change | 1 - changelog.d/oban-update.change | 1 - changelog.d/oban_gun_snooze.change | 1 - changelog.d/poll-refresh.change | 1 - changelog.d/profile-image-descriptions.add | 1 - changelog.d/profile-image-descriptions.skip | 0 changelog.d/publisher-reachability.fix | 1 - changelog.d/release-tuning.change | 1 - changelog.d/remote-object-fetcher.fix | 1 - changelog.d/remote-report-policy.add | 1 - changelog.d/rich-media-no-heads.change | 1 - .../scrubbers-allow-mention-hashtag.add | 1 - changelog.d/se-opt-out.change | 1 - .../stream-follow-relationships-count.fix | 1 - changelog.d/swoosh-mua.add | 1 - changelog.d/text-extensions.skip | 0 changelog.d/todo-cleanup.skip | 0 changelog.d/token-view-scopes.add | 1 - changelog.d/update-oban.change | 1 - changelog.d/user-factory.skip | 0 changelog.d/user-imports.fix | 1 - changelog.d/vapid_keyword_fallback.fix | 1 - changelog.d/workerhelper.change | 1 - 66 files changed, 59 insertions(+), 50 deletions(-) delete mode 100644 changelog.d/activity-pub-metadata.add delete mode 100644 changelog.d/argon2-passwords.add delete mode 100644 changelog.d/atom-tag.change delete mode 100644 changelog.d/bump-lexbor.change delete mode 100644 changelog.d/c2s-update-verify.fix delete mode 100644 changelog.d/ci-git-fetch.skip delete mode 100644 changelog.d/commonapi.skip delete mode 100644 changelog.d/debian-install-improve.skip delete mode 100644 changelog.d/dedupe-sharding.change delete mode 100644 changelog.d/deprecate-subscribe.change delete mode 100644 changelog.d/dialyzer.skip delete mode 100644 changelog.d/docs-fix.skip delete mode 100644 changelog.d/docs-vips.skip delete mode 100644 changelog.d/drop-unwanted.change delete mode 100644 changelog.d/elixir-1.14-docker.skip delete mode 100644 changelog.d/elixir.change delete mode 100644 changelog.d/follow-request.fix delete mode 100644 changelog.d/freebsd-docs.skip delete mode 100644 changelog.d/get-statuses-param.change delete mode 100644 changelog.d/hashtag-feeds-restricted.add delete mode 100644 changelog.d/identity-proofs.remove delete mode 100644 changelog.d/incoming-blocks.fix delete mode 100644 changelog.d/ldap-ca.add delete mode 100644 changelog.d/ldap-password-change.add delete mode 100644 changelog.d/ldap-refactor.change delete mode 100644 changelog.d/ldap-tls.fix delete mode 100644 changelog.d/ldap-warning.skip delete mode 100644 changelog.d/ldaps.fix delete mode 100644 changelog.d/list-id-visibility.add delete mode 100644 changelog.d/manifest-icon-size.skip delete mode 100644 changelog.d/mediav2_status.fix delete mode 100644 changelog.d/meilisearch-misc-fixes.fix delete mode 100644 changelog.d/module-search-in-pleroma-ctl.fix delete mode 100644 changelog.d/mogrify.skip delete mode 100644 changelog.d/mrf-cleanup.skip delete mode 100644 changelog.d/mrf-fodirectreply.add delete mode 100644 changelog.d/mrf-id_filter.add delete mode 100644 changelog.d/mrf-quietreply.add delete mode 100644 changelog.d/notifications-group-key.add delete mode 100644 changelog.d/notifications-marker.change delete mode 100644 changelog.d/oauth-app-spam.fix delete mode 100644 changelog.d/oban-recevier-improvements.fix delete mode 100644 changelog.d/oban-uniques.change delete mode 100644 changelog.d/oban-update.change delete mode 100644 changelog.d/oban_gun_snooze.change delete mode 100644 changelog.d/poll-refresh.change delete mode 100644 changelog.d/profile-image-descriptions.add delete mode 100644 changelog.d/profile-image-descriptions.skip delete mode 100644 changelog.d/publisher-reachability.fix delete mode 100644 changelog.d/release-tuning.change delete mode 100644 changelog.d/remote-object-fetcher.fix delete mode 100644 changelog.d/remote-report-policy.add delete mode 100644 changelog.d/rich-media-no-heads.change delete mode 100644 changelog.d/scrubbers-allow-mention-hashtag.add delete mode 100644 changelog.d/se-opt-out.change delete mode 100644 changelog.d/stream-follow-relationships-count.fix delete mode 100644 changelog.d/swoosh-mua.add delete mode 100644 changelog.d/text-extensions.skip delete mode 100644 changelog.d/todo-cleanup.skip delete mode 100644 changelog.d/token-view-scopes.add delete mode 100644 changelog.d/update-oban.change delete mode 100644 changelog.d/user-factory.skip delete mode 100644 changelog.d/user-imports.fix delete mode 100644 changelog.d/vapid_keyword_fallback.fix delete mode 100644 changelog.d/workerhelper.change diff --git a/CHANGELOG.md b/CHANGELOG.md index 424a9afbb..71178c89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,65 @@ 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/). +## 2.8.0 + +### Changed +- Metadata: Do not include .atom feed links for remote accounts +- Bumped `fast_html` to v2.3.0, which notably allows to use system-installed lexbor with passing `WITH_SYSTEM_LEXBOR=1` environment variable at build-time +- Dedupe upload filter now uses a three-level sharding directory structure +- Deprecate `/api/v1/pleroma/accounts/:id/subscribe`/`unsubscribe` +- Restrict incoming activities from unknown actors to a subset that does not imply a previous relationship and early rejection of unrecognized activity types. +- Elixir 1.14 and Erlang/OTP 23 is now the minimum supported release +- Support `id` param in `GET /api/v1/statuses` +- LDAP authentication has been refactored to operate as a GenServer process which will maintain an active connection to the LDAP server. +- Fix 'Setting a marker should mark notifications as read' +- Adjust more Oban workers to enforce unique job constraints. +- Oban updated to 2.18.3 +- Publisher behavior improvement when snoozing Oban jobs due to Gun connection pool contention. +- Poll results refreshing is handled asynchronously and will not attempt to keep fetching updates to a closed poll. +- Tuning for release builds to lower CPU usage. +- Rich Media preview fetching will skip making an HTTP HEAD request to check a URL for allowed content type and length if the Tesla adapter is Gun or Finch +- Fix nonexisting user will not generate metadata for search engine opt-out +- Update Oban to 2.18 +- Worker configuration is no longer available. This only affects custom max_retries values for a couple Oban queues. + +### Added +- Add metadata provider for ActivityPub alternate links +- Added support for argon2 passwords and their conversion for migration from Akkoma fork to upstream. +- Respect :restrict_unauthenticated for hashtag rss/atom feeds +- LDAP configuration now permits overriding the CA root certificate file for TLS validation. +- LDAP now supports users changing their passwords +- Include list id in StatusView +- Added MRF.FODirectReply which changes replies to followers-only posts to be direct. +- Add `id_filter` to MRF to filter URLs and their domain prior to fetching +- Added MRF.QuietReply which prevents replies to public posts from being published to the timelines +- Add `group_key` to notifications +- Allow providing avatar/header descriptions +- Added RemoteReportPolicy from Rebased for handling bogus federated reports +- scrubbers/default: Allow "mention hashtag" classes used by Mastodon +- Added dependencies for Swoosh's Mua mail adapter +- Include session scopes in TokenView + +### Fixed +- Verify a local Update sent through AP C2S so users can only update their own objects +- Fixed malformed follow requests that cause them to appear stuck pending due to the recipient being unable to process them. +- Fix incoming Block activities being rejected +- STARTTLS certificate and hostname verification for LDAP authentication +- LDAPS connections (implicit TLS) are now supported. +- Fix /api/v2/media returning the wrong status code (202) for media processed synchronously +- Miscellaneous fixes for Meilisearch support +- Fix pleroma_ctl mix task calls sometimes not being found +- Add a rate limiter to the OAuth App creation endpoint and ensure registered apps are assigned to users. +- ReceiverWorker will cancel processing jobs instead of retrying if the user cannot be fetched due to 403, 404, or 410 errors or if the account is disabled locally. +- Address case where instance reachability status couldn't be updated +- Remote Fetcher Worker recognizes more permanent failure errors +- StreamerView: Do not leak follows count if hidden +- Imports of blocks, mutes, and follows would retry repeatedly due to incorrect error handling and all work executed in a single job +- Make vapid_config return empty array, fixing preloading for instances without push notifications configured + +### Removed +- Remove stub for /api/v1/accounts/:id/identity_proofs (deprecated by Mastodon 3.5.0) + ## 2.7.1 ### Changed diff --git a/changelog.d/activity-pub-metadata.add b/changelog.d/activity-pub-metadata.add deleted file mode 100644 index 2ad3d7b2d..000000000 --- a/changelog.d/activity-pub-metadata.add +++ /dev/null @@ -1 +0,0 @@ -Add metadata provider for ActivityPub alternate links diff --git a/changelog.d/argon2-passwords.add b/changelog.d/argon2-passwords.add deleted file mode 100644 index 36fd7faf2..000000000 --- a/changelog.d/argon2-passwords.add +++ /dev/null @@ -1 +0,0 @@ -Added support for argon2 passwords and their conversion for migration from Akkoma fork to upstream. diff --git a/changelog.d/atom-tag.change b/changelog.d/atom-tag.change deleted file mode 100644 index 1b3590dea..000000000 --- a/changelog.d/atom-tag.change +++ /dev/null @@ -1 +0,0 @@ -Metadata: Do not include .atom feed links for remote accounts diff --git a/changelog.d/bump-lexbor.change b/changelog.d/bump-lexbor.change deleted file mode 100644 index 2c7061a81..000000000 --- a/changelog.d/bump-lexbor.change +++ /dev/null @@ -1 +0,0 @@ -- Bumped `fast_html` to v2.3.0, which notably allows to use system-installed lexbor with passing `WITH_SYSTEM_LEXBOR=1` environment variable at build-time \ No newline at end of file diff --git a/changelog.d/c2s-update-verify.fix b/changelog.d/c2s-update-verify.fix deleted file mode 100644 index a4dfe7c07..000000000 --- a/changelog.d/c2s-update-verify.fix +++ /dev/null @@ -1 +0,0 @@ -Verify a local Update sent through AP C2S so users can only update their own objects diff --git a/changelog.d/ci-git-fetch.skip b/changelog.d/ci-git-fetch.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/commonapi.skip b/changelog.d/commonapi.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/debian-install-improve.skip b/changelog.d/debian-install-improve.skip deleted file mode 100644 index 6068a3066..000000000 --- a/changelog.d/debian-install-improve.skip +++ /dev/null @@ -1 +0,0 @@ -Fixed a formatting issue that had a required commend embedded in a textblock, and change the language to make it a bit more idiomatic. \ No newline at end of file diff --git a/changelog.d/dedupe-sharding.change b/changelog.d/dedupe-sharding.change deleted file mode 100644 index 2e140d8a2..000000000 --- a/changelog.d/dedupe-sharding.change +++ /dev/null @@ -1 +0,0 @@ -Dedupe upload filter now uses a three-level sharding directory structure diff --git a/changelog.d/deprecate-subscribe.change b/changelog.d/deprecate-subscribe.change deleted file mode 100644 index bd7e8aec7..000000000 --- a/changelog.d/deprecate-subscribe.change +++ /dev/null @@ -1 +0,0 @@ -Deprecate `/api/v1/pleroma/accounts/:id/subscribe`/`unsubscribe` \ No newline at end of file diff --git a/changelog.d/dialyzer.skip b/changelog.d/dialyzer.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/docs-fix.skip b/changelog.d/docs-fix.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/docs-vips.skip b/changelog.d/docs-vips.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/drop-unwanted.change b/changelog.d/drop-unwanted.change deleted file mode 100644 index 459d4bfe6..000000000 --- a/changelog.d/drop-unwanted.change +++ /dev/null @@ -1 +0,0 @@ -Restrict incoming activities from unknown actors to a subset that does not imply a previous relationship and early rejection of unrecognized activity types. diff --git a/changelog.d/elixir-1.14-docker.skip b/changelog.d/elixir-1.14-docker.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/elixir.change b/changelog.d/elixir.change deleted file mode 100644 index 779c01562..000000000 --- a/changelog.d/elixir.change +++ /dev/null @@ -1 +0,0 @@ -Elixir 1.14 and Erlang/OTP 23 is now the minimum supported release diff --git a/changelog.d/follow-request.fix b/changelog.d/follow-request.fix deleted file mode 100644 index 59d34e9bf..000000000 --- a/changelog.d/follow-request.fix +++ /dev/null @@ -1 +0,0 @@ -Fixed malformed follow requests that cause them to appear stuck pending due to the recipient being unable to process them. diff --git a/changelog.d/freebsd-docs.skip b/changelog.d/freebsd-docs.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/get-statuses-param.change b/changelog.d/get-statuses-param.change deleted file mode 100644 index 3edcad268..000000000 --- a/changelog.d/get-statuses-param.change +++ /dev/null @@ -1 +0,0 @@ -Support `id` param in `GET /api/v1/statuses` \ No newline at end of file diff --git a/changelog.d/hashtag-feeds-restricted.add b/changelog.d/hashtag-feeds-restricted.add deleted file mode 100644 index accac9c9c..000000000 --- a/changelog.d/hashtag-feeds-restricted.add +++ /dev/null @@ -1 +0,0 @@ -Repesct :restrict_unauthenticated for hashtag rss/atom feeds \ No newline at end of file diff --git a/changelog.d/identity-proofs.remove b/changelog.d/identity-proofs.remove deleted file mode 100644 index efe1c34f5..000000000 --- a/changelog.d/identity-proofs.remove +++ /dev/null @@ -1 +0,0 @@ -Remove stub for /api/v1/accounts/:id/identity_proofs (deprecated by Mastodon 3.5.0) \ No newline at end of file diff --git a/changelog.d/incoming-blocks.fix b/changelog.d/incoming-blocks.fix deleted file mode 100644 index 3228d7318..000000000 --- a/changelog.d/incoming-blocks.fix +++ /dev/null @@ -1 +0,0 @@ -Fix incoming Block activities being rejected diff --git a/changelog.d/ldap-ca.add b/changelog.d/ldap-ca.add deleted file mode 100644 index 32ecbb5c0..000000000 --- a/changelog.d/ldap-ca.add +++ /dev/null @@ -1 +0,0 @@ -LDAP configuration now permits overriding the CA root certificate file for TLS validation. diff --git a/changelog.d/ldap-password-change.add b/changelog.d/ldap-password-change.add deleted file mode 100644 index 7ca555ee4..000000000 --- a/changelog.d/ldap-password-change.add +++ /dev/null @@ -1 +0,0 @@ -LDAP now supports users changing their passwords diff --git a/changelog.d/ldap-refactor.change b/changelog.d/ldap-refactor.change deleted file mode 100644 index 1510eea6a..000000000 --- a/changelog.d/ldap-refactor.change +++ /dev/null @@ -1 +0,0 @@ -LDAP authentication has been refactored to operate as a GenServer process which will maintain an active connection to the LDAP server. diff --git a/changelog.d/ldap-tls.fix b/changelog.d/ldap-tls.fix deleted file mode 100644 index b15137d77..000000000 --- a/changelog.d/ldap-tls.fix +++ /dev/null @@ -1 +0,0 @@ -STARTTLS certificate and hostname verification for LDAP authentication diff --git a/changelog.d/ldap-warning.skip b/changelog.d/ldap-warning.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/ldaps.fix b/changelog.d/ldaps.fix deleted file mode 100644 index a1dc901ab..000000000 --- a/changelog.d/ldaps.fix +++ /dev/null @@ -1 +0,0 @@ -LDAPS connections (implicit TLS) are now supported. diff --git a/changelog.d/list-id-visibility.add b/changelog.d/list-id-visibility.add deleted file mode 100644 index 2fea2d771..000000000 --- a/changelog.d/list-id-visibility.add +++ /dev/null @@ -1 +0,0 @@ -Include list id in StatusView \ No newline at end of file diff --git a/changelog.d/manifest-icon-size.skip b/changelog.d/manifest-icon-size.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/mediav2_status.fix b/changelog.d/mediav2_status.fix deleted file mode 100644 index 28e93e030..000000000 --- a/changelog.d/mediav2_status.fix +++ /dev/null @@ -1 +0,0 @@ -Fix /api/v2/media returning the wrong status code (202) for media processed synchronously diff --git a/changelog.d/meilisearch-misc-fixes.fix b/changelog.d/meilisearch-misc-fixes.fix deleted file mode 100644 index 0f127d3a8..000000000 --- a/changelog.d/meilisearch-misc-fixes.fix +++ /dev/null @@ -1 +0,0 @@ -Miscellaneous fixes for Meilisearch support diff --git a/changelog.d/module-search-in-pleroma-ctl.fix b/changelog.d/module-search-in-pleroma-ctl.fix deleted file mode 100644 index d32fe3f33..000000000 --- a/changelog.d/module-search-in-pleroma-ctl.fix +++ /dev/null @@ -1 +0,0 @@ -Fix pleroma_ctl mix task calls sometimes not being found diff --git a/changelog.d/mogrify.skip b/changelog.d/mogrify.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/mrf-cleanup.skip b/changelog.d/mrf-cleanup.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/mrf-fodirectreply.add b/changelog.d/mrf-fodirectreply.add deleted file mode 100644 index 10fd5d16a..000000000 --- a/changelog.d/mrf-fodirectreply.add +++ /dev/null @@ -1 +0,0 @@ -Added MRF.FODirectReply which changes replies to followers-only posts to be direct. diff --git a/changelog.d/mrf-id_filter.add b/changelog.d/mrf-id_filter.add deleted file mode 100644 index f556f9bc4..000000000 --- a/changelog.d/mrf-id_filter.add +++ /dev/null @@ -1 +0,0 @@ -Add `id_filter` to MRF to filter URLs and their domain prior to fetching \ No newline at end of file diff --git a/changelog.d/mrf-quietreply.add b/changelog.d/mrf-quietreply.add deleted file mode 100644 index 4ed20bce6..000000000 --- a/changelog.d/mrf-quietreply.add +++ /dev/null @@ -1 +0,0 @@ -Added MRF.QuietReply which prevents replies to public posts from being published to the timelines diff --git a/changelog.d/notifications-group-key.add b/changelog.d/notifications-group-key.add deleted file mode 100644 index 386927f4a..000000000 --- a/changelog.d/notifications-group-key.add +++ /dev/null @@ -1 +0,0 @@ -Add `group_key` to notifications \ No newline at end of file diff --git a/changelog.d/notifications-marker.change b/changelog.d/notifications-marker.change deleted file mode 100644 index 9e350a95c..000000000 --- a/changelog.d/notifications-marker.change +++ /dev/null @@ -1 +0,0 @@ -Fix 'Setting a marker should mark notifications as read' \ No newline at end of file diff --git a/changelog.d/oauth-app-spam.fix b/changelog.d/oauth-app-spam.fix deleted file mode 100644 index cdc2e816d..000000000 --- a/changelog.d/oauth-app-spam.fix +++ /dev/null @@ -1 +0,0 @@ -Add a rate limiter to the OAuth App creation endpoint and ensure registered apps are assigned to users. diff --git a/changelog.d/oban-recevier-improvements.fix b/changelog.d/oban-recevier-improvements.fix deleted file mode 100644 index f91502ed2..000000000 --- a/changelog.d/oban-recevier-improvements.fix +++ /dev/null @@ -1 +0,0 @@ -ReceiverWorker will cancel processing jobs instead of retrying if the user cannot be fetched due to 403, 404, or 410 errors or if the account is disabled locally. diff --git a/changelog.d/oban-uniques.change b/changelog.d/oban-uniques.change deleted file mode 100644 index d9deb4696..000000000 --- a/changelog.d/oban-uniques.change +++ /dev/null @@ -1 +0,0 @@ -Adjust more Oban workers to enforce unique job constraints. diff --git a/changelog.d/oban-update.change b/changelog.d/oban-update.change deleted file mode 100644 index 48a54ed2d..000000000 --- a/changelog.d/oban-update.change +++ /dev/null @@ -1 +0,0 @@ -Oban updated to 2.18.3 diff --git a/changelog.d/oban_gun_snooze.change b/changelog.d/oban_gun_snooze.change deleted file mode 100644 index c94525b2a..000000000 --- a/changelog.d/oban_gun_snooze.change +++ /dev/null @@ -1 +0,0 @@ -Publisher behavior improvement when snoozing Oban jobs due to Gun connection pool contention. diff --git a/changelog.d/poll-refresh.change b/changelog.d/poll-refresh.change deleted file mode 100644 index b755128a1..000000000 --- a/changelog.d/poll-refresh.change +++ /dev/null @@ -1 +0,0 @@ -Poll results refreshing is handled asynchronously and will not attempt to keep fetching updates to a closed poll. diff --git a/changelog.d/profile-image-descriptions.add b/changelog.d/profile-image-descriptions.add deleted file mode 100644 index 85cc48083..000000000 --- a/changelog.d/profile-image-descriptions.add +++ /dev/null @@ -1 +0,0 @@ -Allow providing avatar/header descriptions \ No newline at end of file diff --git a/changelog.d/profile-image-descriptions.skip b/changelog.d/profile-image-descriptions.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/publisher-reachability.fix b/changelog.d/publisher-reachability.fix deleted file mode 100644 index 3f50be581..000000000 --- a/changelog.d/publisher-reachability.fix +++ /dev/null @@ -1 +0,0 @@ -Address case where instance reachability status couldn't be updated diff --git a/changelog.d/release-tuning.change b/changelog.d/release-tuning.change deleted file mode 100644 index bf9abc3ad..000000000 --- a/changelog.d/release-tuning.change +++ /dev/null @@ -1 +0,0 @@ -Tuning for release builds to lower CPU usage. diff --git a/changelog.d/remote-object-fetcher.fix b/changelog.d/remote-object-fetcher.fix deleted file mode 100644 index dcf2b1b31..000000000 --- a/changelog.d/remote-object-fetcher.fix +++ /dev/null @@ -1 +0,0 @@ -Remote Fetcher Worker recognizes more permanent failure errors diff --git a/changelog.d/remote-report-policy.add b/changelog.d/remote-report-policy.add deleted file mode 100644 index 1cf25b1a8..000000000 --- a/changelog.d/remote-report-policy.add +++ /dev/null @@ -1 +0,0 @@ -Added RemoteReportPolicy from Rebased for handling bogus federated reports diff --git a/changelog.d/rich-media-no-heads.change b/changelog.d/rich-media-no-heads.change deleted file mode 100644 index 0bab323aa..000000000 --- a/changelog.d/rich-media-no-heads.change +++ /dev/null @@ -1 +0,0 @@ -Rich Media preview fetching will skip making an HTTP HEAD request to check a URL for allowed content type and length if the Tesla adapter is Gun or Finch diff --git a/changelog.d/scrubbers-allow-mention-hashtag.add b/changelog.d/scrubbers-allow-mention-hashtag.add deleted file mode 100644 index c12ab1ffb..000000000 --- a/changelog.d/scrubbers-allow-mention-hashtag.add +++ /dev/null @@ -1 +0,0 @@ -scrubbers/default: Allow "mention hashtag" classes used by Mastodon \ No newline at end of file diff --git a/changelog.d/se-opt-out.change b/changelog.d/se-opt-out.change deleted file mode 100644 index dd694033f..000000000 --- a/changelog.d/se-opt-out.change +++ /dev/null @@ -1 +0,0 @@ -Fix nonexisting user will not generate metadata for search engine opt-out diff --git a/changelog.d/stream-follow-relationships-count.fix b/changelog.d/stream-follow-relationships-count.fix deleted file mode 100644 index 68452a88b..000000000 --- a/changelog.d/stream-follow-relationships-count.fix +++ /dev/null @@ -1 +0,0 @@ -StreamerView: Do not leak follows count if hidden \ No newline at end of file diff --git a/changelog.d/swoosh-mua.add b/changelog.d/swoosh-mua.add deleted file mode 100644 index d4c4bbd08..000000000 --- a/changelog.d/swoosh-mua.add +++ /dev/null @@ -1 +0,0 @@ -Added dependencies for Swoosh's Mua mail adapter diff --git a/changelog.d/text-extensions.skip b/changelog.d/text-extensions.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/todo-cleanup.skip b/changelog.d/todo-cleanup.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/token-view-scopes.add b/changelog.d/token-view-scopes.add deleted file mode 100644 index e24fa38e6..000000000 --- a/changelog.d/token-view-scopes.add +++ /dev/null @@ -1 +0,0 @@ -Include session scopes in TokenView \ No newline at end of file diff --git a/changelog.d/update-oban.change b/changelog.d/update-oban.change deleted file mode 100644 index a67b3e3cf..000000000 --- a/changelog.d/update-oban.change +++ /dev/null @@ -1 +0,0 @@ -Update Oban to 2.18 diff --git a/changelog.d/user-factory.skip b/changelog.d/user-factory.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/user-imports.fix b/changelog.d/user-imports.fix deleted file mode 100644 index 0076c73d7..000000000 --- a/changelog.d/user-imports.fix +++ /dev/null @@ -1 +0,0 @@ -Imports of blocks, mutes, and follows would retry repeatedly due to incorrect error handling and all work executed in a single job diff --git a/changelog.d/vapid_keyword_fallback.fix b/changelog.d/vapid_keyword_fallback.fix deleted file mode 100644 index aa48f8938..000000000 --- a/changelog.d/vapid_keyword_fallback.fix +++ /dev/null @@ -1 +0,0 @@ -Make vapid_config return empty array, fixing preloading for instances without push notifications configured \ No newline at end of file diff --git a/changelog.d/workerhelper.change b/changelog.d/workerhelper.change deleted file mode 100644 index 539c9b54f..000000000 --- a/changelog.d/workerhelper.change +++ /dev/null @@ -1 +0,0 @@ -Worker configuration is no longer available. This only affects custom max_retries values for a couple Oban queues. From 89e92121c262511ec0b1628caa50cf6470c6fb1b Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 20 Dec 2024 07:35:23 +0400 Subject: [PATCH 203/212] CI: Allow failure for non-musl arm for now --- .gitlab-ci.yml | 1 + changelog.d/ci-builder-skip-arm32.skip | 0 2 files changed, 1 insertion(+) create mode 100644 changelog.d/ci-builder-skip-arm32.skip diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 39947c75e..fd53ab053 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -327,6 +327,7 @@ amd64-musl: arm: stage: release + allow_failure: true artifacts: *release-artifacts only: *release-only tags: diff --git a/changelog.d/ci-builder-skip-arm32.skip b/changelog.d/ci-builder-skip-arm32.skip new file mode 100644 index 000000000..e69de29bb From 7dc90f5ea40c0a44d8b971e70dc1b3b09749e6a1 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 20 Dec 2024 16:14:08 +0400 Subject: [PATCH 204/212] Switch release builder to hexpm images (mostly) --- .gitlab-ci.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fd53ab053..675d0e067 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,7 +2,7 @@ image: git.pleroma.social:5050/pleroma/pleroma/ci-base:elixir-1.14.5-otp-25 variables: &global_variables # Only used for the release - ELIXIR_VER: 1.14.5 + ELIXIR_VER: 1.17.3 POSTGRES_DB: pleroma_test POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -272,7 +272,8 @@ stop_review_app: amd64: stage: release - image: elixir:$ELIXIR_VER + image: + name: hexpm/elixir-amd64:1.17.3-erlang-26.2.5.6-ubuntu-focal-20241011 only: &release-only - stable@pleroma/pleroma - develop@pleroma/pleroma @@ -297,8 +298,9 @@ amd64: variables: &release-variables MIX_ENV: prod VIX_COMPILATION_MODE: PLATFORM_PROVIDED_LIBVIPS + DEBIAN_FRONTEND: noninteractive before_script: &before-release - - apt-get update && apt-get install -y cmake libmagic-dev libvips-dev erlang-dev + - apt-get update && apt-get install -y cmake libmagic-dev libvips-dev erlang-dev git - echo "import Config" > config/prod.secret.exs - mix local.hex --force - mix local.rebar --force @@ -313,7 +315,8 @@ amd64-musl: stage: release artifacts: *release-artifacts only: *release-only - image: elixir:$ELIXIR_VER-alpine + image: + name: hexpm/elixir-amd64:1.17.3-erlang-26.2.5.6-alpine-3.17.9 tags: - amd64 cache: *release-cache @@ -356,7 +359,8 @@ arm64: only: *release-only tags: - arm - image: arm64v8/elixir:$ELIXIR_VER + image: + name: hexpm/elixir-arm64:1.17.3-erlang-26.2.5.6-ubuntu-focal-20241011 cache: *release-cache variables: *release-variables before_script: *before-release @@ -368,7 +372,8 @@ arm64-musl: only: *release-only tags: - arm - image: arm64v8/elixir:$ELIXIR_VER-alpine + image: + name: hexpm/elixir-arm64:1.17.3-erlang-26.2.5.6-alpine-3.17.9 cache: *release-cache variables: *release-variables before_script: *before-release-musl From 6f3d82e2a0685164dfe7d00bc66a4052002e10ee Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Fri, 20 Dec 2024 16:16:54 +0400 Subject: [PATCH 205/212] Add changelog --- changelog.d/hexpm-build-images.skip | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 changelog.d/hexpm-build-images.skip diff --git a/changelog.d/hexpm-build-images.skip b/changelog.d/hexpm-build-images.skip new file mode 100644 index 000000000..e69de29bb From 855294bb3d802b801e3ec064341e4134253089a6 Mon Sep 17 00:00:00 2001 From: mkljczk Date: Thu, 9 Jan 2025 12:58:51 +0100 Subject: [PATCH 206/212] Link to exported outbox/followers/following collections in backup actor.json Signed-off-by: mkljczk --- changelog.d/backup-links.add | 1 + lib/pleroma/user/backup.ex | 8 +++++++- test/pleroma/user/backup_test.exs | 6 +++--- 3 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 changelog.d/backup-links.add diff --git a/changelog.d/backup-links.add b/changelog.d/backup-links.add new file mode 100644 index 000000000..ff19e736b --- /dev/null +++ b/changelog.d/backup-links.add @@ -0,0 +1 @@ +Link to exported outbox/followers/following collections in backup actor.json diff --git a/lib/pleroma/user/backup.ex b/lib/pleroma/user/backup.ex index d77d49890..cdff297a9 100644 --- a/lib/pleroma/user/backup.ex +++ b/lib/pleroma/user/backup.ex @@ -246,7 +246,13 @@ defmodule Pleroma.User.Backup do defp actor(dir, user) do with {:ok, json} <- UserView.render("user.json", %{user: user}) - |> Map.merge(%{"likes" => "likes.json", "bookmarks" => "bookmarks.json"}) + |> Map.merge(%{ + "bookmarks" => "bookmarks.json", + "likes" => "likes.json", + "outbox" => "outbox.json", + "followers" => "followers.json", + "following" => "following.json" + }) |> Jason.encode() do File.write(Path.join(dir, "actor.json"), json) end diff --git a/test/pleroma/user/backup_test.exs b/test/pleroma/user/backup_test.exs index 24fe09f7e..f4b92adf8 100644 --- a/test/pleroma/user/backup_test.exs +++ b/test/pleroma/user/backup_test.exs @@ -185,13 +185,13 @@ defmodule Pleroma.User.BackupTest do %{"@language" => "und"} ], "bookmarks" => "bookmarks.json", - "followers" => "http://cofe.io/users/cofe/followers", - "following" => "http://cofe.io/users/cofe/following", + "followers" => "followers.json", + "following" => "following.json", "id" => "http://cofe.io/users/cofe", "inbox" => "http://cofe.io/users/cofe/inbox", "likes" => "likes.json", "name" => "Cofe", - "outbox" => "http://cofe.io/users/cofe/outbox", + "outbox" => "outbox.json", "preferredUsername" => "cofe", "publicKey" => %{ "id" => "http://cofe.io/users/cofe#main-key", From 38b17933e160beb5923283786ca829af1d6b4036 Mon Sep 17 00:00:00 2001 From: mkljczk Date: Sun, 19 Jan 2025 16:26:46 +0100 Subject: [PATCH 207/212] Include "published" in actor view Signed-off-by: mkljczk --- changelog.d/actor-published-date.add | 1 + lib/pleroma/web/activity_pub/views/user_view.ex | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 changelog.d/actor-published-date.add diff --git a/changelog.d/actor-published-date.add b/changelog.d/actor-published-date.add new file mode 100644 index 000000000..feac85894 --- /dev/null +++ b/changelog.d/actor-published-date.add @@ -0,0 +1 @@ +Include "published" in actor view diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index cd485ed64..61975387b 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -127,7 +127,8 @@ defmodule Pleroma.Web.ActivityPub.UserView do "capabilities" => capabilities, "alsoKnownAs" => user.also_known_as, "vcard:bday" => birthday, - "webfinger" => "acct:#{User.full_nickname(user)}" + "webfinger" => "acct:#{User.full_nickname(user)}", + "published" => Pleroma.Web.CommonAPI.Utils.to_masto_date(user.inserted_at) } |> Map.merge( maybe_make_image( From 22261718907d227a521bb9f898e617ea137c502d Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Tue, 21 Jan 2025 11:59:25 +0400 Subject: [PATCH 208/212] MediaProxyController: Use 301 for permanent redirects --- changelog.d/301-small-image-redirect.change | 1 + .../web/media_proxy/media_proxy_controller.ex | 8 +++-- .../media_proxy_controller_test.exs | 35 ++++++++++++++++--- 3 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 changelog.d/301-small-image-redirect.change diff --git a/changelog.d/301-small-image-redirect.change b/changelog.d/301-small-image-redirect.change new file mode 100644 index 000000000..c5be80539 --- /dev/null +++ b/changelog.d/301-small-image-redirect.change @@ -0,0 +1 @@ +Performance: Use 301 (permanent) redirect instead of 302 (temporary) when redirecting small images in media proxy. This allows browsers to cache the redirect response. \ No newline at end of file diff --git a/lib/pleroma/web/media_proxy/media_proxy_controller.ex b/lib/pleroma/web/media_proxy/media_proxy_controller.ex index 0b446e0a6..a0aafc32e 100644 --- a/lib/pleroma/web/media_proxy/media_proxy_controller.ex +++ b/lib/pleroma/web/media_proxy/media_proxy_controller.ex @@ -71,11 +71,15 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyController do drop_static_param_and_redirect(conn) content_type == "image/gif" -> - redirect(conn, external: media_proxy_url) + conn + |> put_status(301) + |> redirect(external: media_proxy_url) min_content_length_for_preview() > 0 and content_length > 0 and content_length < min_content_length_for_preview() -> - redirect(conn, external: media_proxy_url) + conn + |> put_status(301) + |> redirect(external: media_proxy_url) true -> handle_preview(content_type, conn, media_proxy_url) diff --git a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs index f0c1dd640..f7e52483c 100644 --- a/test/pleroma/web/media_proxy/media_proxy_controller_test.exs +++ b/test/pleroma/web/media_proxy/media_proxy_controller_test.exs @@ -248,8 +248,8 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do response = get(conn, url) - assert response.status == 302 - assert redirected_to(response) == media_proxy_url + assert response.status == 301 + assert redirected_to(response, 301) == media_proxy_url end test "with `static` param and non-GIF image preview requested, " <> @@ -290,8 +290,8 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do response = get(conn, url) - assert response.status == 302 - assert redirected_to(response) == media_proxy_url + assert response.status == 301 + assert redirected_to(response, 301) == media_proxy_url end test "thumbnails PNG images into PNG", %{ @@ -356,5 +356,32 @@ defmodule Pleroma.Web.MediaProxy.MediaProxyControllerTest do assert response.status == 302 assert redirected_to(response) == media_proxy_url end + + test "redirects to media proxy URI with 301 when image is too small for preview", %{ + conn: conn, + url: url, + media_proxy_url: media_proxy_url + } do + clear_config([:media_preview_proxy], + enabled: true, + min_content_length: 1000, + image_quality: 85, + thumbnail_max_width: 100, + thumbnail_max_height: 100 + ) + + Tesla.Mock.mock(fn + %{method: :head, url: ^media_proxy_url} -> + %Tesla.Env{ + status: 200, + body: "", + headers: [{"content-type", "image/png"}, {"content-length", "500"}] + } + end) + + response = get(conn, url) + assert response.status == 301 + assert redirected_to(response, 301) == media_proxy_url + end end end From 4128ea39481a8864bcc4631dbb8d1e3d922473ab Mon Sep 17 00:00:00 2001 From: mkljczk Date: Tue, 21 Jan 2025 18:24:42 +0100 Subject: [PATCH 209/212] description.exs: Remove suggestion referencing a deleted module Signed-off-by: mkljczk --- changelog.d/description-update-suggestions.skip | 0 config/description.exs | 3 +-- 2 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 changelog.d/description-update-suggestions.skip diff --git a/changelog.d/description-update-suggestions.skip b/changelog.d/description-update-suggestions.skip new file mode 100644 index 000000000..e69de29bb diff --git a/config/description.exs b/config/description.exs index 47f4771eb..e8d154124 100644 --- a/config/description.exs +++ b/config/description.exs @@ -3302,8 +3302,7 @@ config :pleroma, :config_description, [ suggestions: [ Pleroma.Web.Preload.Providers.Instance, Pleroma.Web.Preload.Providers.User, - Pleroma.Web.Preload.Providers.Timelines, - Pleroma.Web.Preload.Providers.StatusNet + Pleroma.Web.Preload.Providers.Timelines ] } ] From 8cd77168726e2e44d7612c29914c6b6398ff675d Mon Sep 17 00:00:00 2001 From: mkljczk Date: Tue, 28 Jan 2025 22:28:34 +0100 Subject: [PATCH 210/212] Fix Mastodon incoming edits with inlined "likes" Signed-off-by: mkljczk --- changelog.d/fix-mastodon-edits.fix | 1 + .../article_note_page_validator.ex | 1 + .../audio_image_video_validator.ex | 1 + .../object_validators/common_fixes.ex | 7 ++ .../object_validators/event_validator.ex | 1 + .../object_validators/question_validator.ex | 1 + test/fixtures/mastodon-update-with-likes.json | 90 +++++++++++++++++++ .../article_note_page_validator_test.exs | 11 +++ 8 files changed, 113 insertions(+) create mode 100644 changelog.d/fix-mastodon-edits.fix create mode 100644 test/fixtures/mastodon-update-with-likes.json diff --git a/changelog.d/fix-mastodon-edits.fix b/changelog.d/fix-mastodon-edits.fix new file mode 100644 index 000000000..2e79977e0 --- /dev/null +++ b/changelog.d/fix-mastodon-edits.fix @@ -0,0 +1 @@ +Fix Mastodon incoming edits with inlined "likes" diff --git a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex index 1b5b2e8fb..ada1a4ea9 100644 --- a/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/article_note_page_validator.ex @@ -85,6 +85,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidator do |> fix_replies() |> fix_attachments() |> CommonFixes.fix_quote_url() + |> CommonFixes.fix_likes() |> Transmogrifier.fix_emoji() |> Transmogrifier.fix_content_map() end diff --git a/lib/pleroma/web/activity_pub/object_validators/audio_image_video_validator.ex b/lib/pleroma/web/activity_pub/object_validators/audio_image_video_validator.ex index 65ac6bb93..034c6f33f 100644 --- a/lib/pleroma/web/activity_pub/object_validators/audio_image_video_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/audio_image_video_validator.ex @@ -100,6 +100,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.AudioImageVideoValidator do |> CommonFixes.fix_actor() |> CommonFixes.fix_object_defaults() |> CommonFixes.fix_quote_url() + |> CommonFixes.fix_likes() |> Transmogrifier.fix_emoji() |> fix_url() |> fix_content() 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 4699029d4..a39110e10 100644 --- a/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex +++ b/lib/pleroma/web/activity_pub/object_validators/common_fixes.ex @@ -114,6 +114,13 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonFixes do def fix_quote_url(data), do: data + # On Mastodon, `"likes"` attribute includes an inlined `Collection` with `totalItems`, + # not a list of users. + # https://github.com/mastodon/mastodon/pull/32007 + def fix_likes(%{"likes" => %{}} = data), do: Map.drop(data, ["likes"]) + + def fix_likes(data), do: data + # https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md def object_link_tag?(%{ "type" => "Link", diff --git a/lib/pleroma/web/activity_pub/object_validators/event_validator.ex b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex index ab204f69a..c87515e80 100644 --- a/lib/pleroma/web/activity_pub/object_validators/event_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/event_validator.ex @@ -47,6 +47,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.EventValidator do data |> CommonFixes.fix_actor() |> CommonFixes.fix_object_defaults() + |> CommonFixes.fix_likes() |> Transmogrifier.fix_emoji() 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 7f9d4d648..21940f4f1 100644 --- a/lib/pleroma/web/activity_pub/object_validators/question_validator.ex +++ b/lib/pleroma/web/activity_pub/object_validators/question_validator.ex @@ -64,6 +64,7 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.QuestionValidator do |> CommonFixes.fix_actor() |> CommonFixes.fix_object_defaults() |> CommonFixes.fix_quote_url() + |> CommonFixes.fix_likes() |> Transmogrifier.fix_emoji() |> fix_closed() end diff --git a/test/fixtures/mastodon-update-with-likes.json b/test/fixtures/mastodon-update-with-likes.json new file mode 100644 index 000000000..3bdb3ba3d --- /dev/null +++ b/test/fixtures/mastodon-update-with-likes.json @@ -0,0 +1,90 @@ +{ + "@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" + }, + "https://w3id.org/security/v1" + ], + "actor": "https://pol.social/users/mkljczk", + "cc": ["https://www.w3.org/ns/activitystreams#Public", + "https://pol.social/users/aemstuz", "https://gts.mkljczk.pl/users/mkljczk", + "https://pl.fediverse.pl/users/mkljczk", + "https://fedi.kutno.pl/users/mkljczk"], + "id": "https://pol.social/users/mkljczk/statuses/113907871635572263#updates/1738096776", + "object": { + "atomUri": "https://pol.social/users/mkljczk/statuses/113907871635572263", + "attachment": [], + "attributedTo": "https://pol.social/users/mkljczk", + "cc": ["https://www.w3.org/ns/activitystreams#Public", + "https://pol.social/users/aemstuz", "https://gts.mkljczk.pl/users/mkljczk", + "https://pl.fediverse.pl/users/mkljczk", + "https://fedi.kutno.pl/users/mkljczk"], + "content": "

test

", + "contentMap": { + "pl": "

test

" + }, + "conversation": "https://fedi.kutno.pl/contexts/43c14c70-d3fb-42b4-a36d-4eacfab9695a", + "id": "https://pol.social/users/mkljczk/statuses/113907871635572263", + "inReplyTo": "https://pol.social/users/aemstuz/statuses/113907854282654767", + "inReplyToAtomUri": "https://pol.social/users/aemstuz/statuses/113907854282654767", + "likes": { + "id": "https://pol.social/users/mkljczk/statuses/113907871635572263/likes", + "totalItems": 1, + "type": "Collection" + }, + "published": "2025-01-28T20:29:45Z", + "replies": { + "first": { + "items": [], + "next": "https://pol.social/users/mkljczk/statuses/113907871635572263/replies?only_other_accounts=true&page=true", + "partOf": "https://pol.social/users/mkljczk/statuses/113907871635572263/replies", + "type": "CollectionPage" + }, + "id": "https://pol.social/users/mkljczk/statuses/113907871635572263/replies", + "type": "Collection" + }, + "sensitive": false, + "shares": { + "id": "https://pol.social/users/mkljczk/statuses/113907871635572263/shares", + "totalItems": 0, + "type": "Collection" + }, + "summary": null, + "tag": [ + { + "href": "https://pol.social/users/aemstuz", + "name": "@aemstuz", + "type": "Mention" + }, + { + "href": "https://gts.mkljczk.pl/users/mkljczk", + "name": "@mkljczk@gts.mkljczk.pl", + "type": "Mention" + }, + { + "href": "https://pl.fediverse.pl/users/mkljczk", + "name": "@mkljczk@fediverse.pl", + "type": "Mention" + }, + { + "href": "https://fedi.kutno.pl/users/mkljczk", + "name": "@mkljczk@fedi.kutno.pl", + "type": "Mention" + } + ], + "to": ["https://pol.social/users/mkljczk/followers"], + "type": "Note", + "updated": "2025-01-28T20:39:36Z", + "url": "https://pol.social/@mkljczk/113907871635572263" + }, + "published": "2025-01-28T20:39:36Z", + "to": ["https://pol.social/users/mkljczk/followers"], + "type": "Update" +} diff --git a/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs b/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs index e1dbb20c3..b1cbdbe81 100644 --- a/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs +++ b/test/pleroma/web/activity_pub/object_validators/article_note_page_validator_test.exs @@ -128,6 +128,17 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.ArticleNotePageValidatorTest %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note) end + test "a Note with validated likes collection validates" do + insert(:user, ap_id: "https://pol.social/users/mkljczk") + + %{"object" => note} = + "test/fixtures/mastodon-update-with-likes.json" + |> File.read!() + |> Jason.decode!() + + %{valid?: true} = ArticleNotePageValidator.cast_and_validate(note) + end + test "Fedibird quote post" do insert(:user, ap_id: "https://fedibird.com/users/noellabo") From 81ab906466f8e46ac2a16011faa8d0c2bd009957 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 30 Jan 2025 12:18:20 +0400 Subject: [PATCH 211/212] AnalyzeMetadata: Don't crash on grayscale image blurhash --- lib/pleroma/upload/filter/analyze_metadata.ex | 10 +++++++--- test/fixtures/break_analyze.png | Bin 0 -> 368176 bytes .../upload/filter/analyze_metadata_test.exs | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/break_analyze.png diff --git a/lib/pleroma/upload/filter/analyze_metadata.ex b/lib/pleroma/upload/filter/analyze_metadata.ex index 7ee643277..a8480bf36 100644 --- a/lib/pleroma/upload/filter/analyze_metadata.ex +++ b/lib/pleroma/upload/filter/analyze_metadata.ex @@ -90,9 +90,13 @@ defmodule Pleroma.Upload.Filter.AnalyzeMetadata do {:ok, rgb} = if Image.has_alpha?(resized_image) do # remove alpha channel - resized_image - |> Operation.extract_band!(0, n: 3) - |> Image.write_to_binary() + case Operation.extract_band(resized_image, 0, n: 3) do + {:ok, data} -> + Image.write_to_binary(data) + + _ -> + Image.write_to_binary(resized_image) + end else Image.write_to_binary(resized_image) end diff --git a/test/fixtures/break_analyze.png b/test/fixtures/break_analyze.png new file mode 100644 index 0000000000000000000000000000000000000000..b5e91b08a651f1dd53773342c99050a72532bba0 GIT binary patch literal 368176 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

+`k9OR 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%0AM{_JOM6RUQNif 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

Nl_~jof^%2PwSj z%SQo*cReEnNTgCnkzuorp=AIwi?L0n8Dg5U4Ee~hE+W*81VnWl7+`2ugPV|fT+}o; zB;Z=>g!k+WW^~N|U1#*|{BMv|5(eCLYJGZ2P;Z_R_w;!EGxi{FftYn3qM}8Ok{;Lp zKvAC^hKIEuQxmnK&tPYkx8q>DA^Yx^gP%y#=)ebw&dj2IHi5Bht&Et4IvlP=>Gmgg z0zlojnyQ|EMwQ@<lF1pGNddOT z$t6ifbU*br%1Yo7# zuP?wn&M$#l2XEGdoD&wRcr)6LXd7xq06SU*<9uANtZ!~h3M`c_KI2O3udE7cRxIiq zH^k5;3WPi*xYc+D5E_rugZg}W6fBv;aiwZ7_;4ej-$C^cLj(SYp;5y8NYd35U>vzW zg+%|l)Gd-87#hQ9)#zT@%*m|(qYAp;`QKrdNgSY!KEK!?@&#j7`;n>?q8$qz9>c-7 zfcF2^75~%DMS!Um-OH!-f@Meb^V#^A?PvexBYqyBefk*7Q5LgZ15?BgNl_-Hdr8a= zZ2vsT|F1j5^z;cfALmQJWd}hKAmgqfiPmZ zC9b) zu5C+1f`GEBl1_Oild!}teUgKoNlM-3s~+4L#?;~MP9XQ}#{yMqt*tc<{y|*aVNEtj z8?3K72^UBb!QY?vHwAH0$1w8|vl!nS9%XC2g5h!eDbLiQ2hq88O(5^NfTCP3j25kQ zAyIB*5jSd9tHFQO4ChvVD17@swwffex2ap8)R5`Z|H_dXSx{g(Q>K|8UJFqNVvvW| zQ2xxamqUFZZud5;8m!I8vFLuliyt6cMmn%9U(jzo%PY75FmK}@CQ7f&1_Lo|wUU%q z5f+T3X{FUI1)-Q_I=juM++ZE`&7~X=tb$vMA`YcwyV@C>08*~EwA&|RZc+U8zU|{X zID6w|#lrcO?C_Y)cS#QG%(l~MHXl_6L@72lp9`21-XW|LQT#S9ZTV8LeY1Hf;a9E+ zY*c_X7Cxb?(e2;W4N-uZ0m6Z6&OLXGgpFDMjU444JKHry6;2=?gldgAYf~J191tqv6YAM03W*tO+kdVs zH=xVC=GCi9E2LYg>r5`J)5{+>M`vBHdYJ7%9!Y%!Vxa*`cBs z${*Jk&#aazJ@y{#wwxf@~3mZJ`osLOW{km@!=KQ zb(k?*(*s_O*7qG2gFY|xLgj;h#;L*l>*k{&Gi<_j3AN%LUvQ8Hdx4sT$`@@@TDkqv zbq(qVnk(muP@xF~9%zCw_bd7FS;>ihf1_Kg$oMy_9plK&%zaMJ%F(wytG})3ryE*q zL6NWb#GA3NDg6cvkEbP?-!~1j2XR9x#$DpCB$MnC6f8uG=QX>e^6GkL0$q@9sI=q1 zI$c91uAI|sgG8^BoXMLTc{6Q3?gYS>hqqqa$G{ydTS$BtRyPX@doXOm|CnZsxA+Tk zKfR9iCny~zI{Zr>U;LNoR18t>xDCAlB3@dy&WLZ`?U;8_+v>x@WgJXuFxAMTjE`p( z9hXFuP6Di}eJ01eL@G2nQpeh6YJ7Go#^Tnr6H7eMvNYkgs%^%Bk1OU(%s7xjF()EU z7!O>dpo4c>1Ull|(r2c{6Wz}^6aAfl&UqA2HBFZdMBQpof5W2H*J#$`50Zg6>0@%YRoU^XG&ty-ua2V)B(VUYdyEfL88p};+tujGPOf8X?F9J%fnpC?iSI>nzo(# z2`T}#q7a?-Esf+!BZ!HjaBW3uWmUM{$-GAHEAw~9pRmo26y++W7N zCGl*jZU#a}>lU#ZnQJSWy@|UE!dUzifgIU7wPHc)&WF7MY^> zBT4-h8D|9zAW0npZ-EQQ{}B3AmW+!^5M~C8G`P(sEtSZ2;+s4Tx02y~9nDpeB|f@n zNUREkXm59F&;=)%a`LNINcKWj1r-Tu0gdzH(1ElRp;=E{w0FtS<|)15?&T=(S9&|Ml#=lZm>J%WW7{R4(` zx!Bd`m8f~_MxI800Sh?=G>vz8X@0D*e!a(@nt|;0gY1}7*$bdh-p@@%ma%}8Kw`j>24jrQ_hq~ zfZm*fA_4I9U?}$aW-2 ziO#mb9F@}(6eYjnpQYtZ6Y_HCu2mUGl^&6cB=k*Zob2kWI8ZOnTtYe*wOF zW=v3^@_rxWzk7K+CmI>AEi|3P;h%Q9Dczd3`FAmElH2Dc5<3$8QKzce_xTEk-c?#D z3Og~E?FX1ScrcxrCE4`o=lnUc(+6Tnhj4|t`18pzycQ~UYd!rBaUThzTUX|eZ@B&Rz+3Aw8`5W^uWa#VH2V8}NAHWc zYBT$<$DuYfUFc!rNX*>rWEP%b^+@HPPlz4g>}c8#0=*ILZROu6CshQ$KTqfvg^cr` zOx=&NYH)fa`|t#wroY)IwDstaNRO+>Ee$EIC{M+SJ#OsO z(BmIHt>6aq81Z~9qrCkAv@Vm9x1R62#!FL4fmI}--TW}|0lD@ejuRN!=yJWB;KFcN zI&jo* zsficl<~~&0`fi`Xa2ixzQ*61+tidG(u0#g>bZv)%cDLIx6{D%m-(DtbXqaUSe-ERn zMV$N-(oa?V7Z|@G{04Mx9&;~>dVQi&%TzGzo_~iw=$y*mW9am@>vtToA?DGY9(!m# zTjrZ&0~2Rt+z9cr%tY6Ecpd1uU~{H<{{w%soPK(D3q`_I_eGntFNtv&>_`$b#_!6sJdi zh<=0#lP-jl@SBHshvKmME?H-iSLKv8$IlK@-$?>cXNRVxA!bULymo}8ze(Ps5$RlO z#}Whb)RwQW4ckm4{5~ke?i3zb(6lu54n&?0|LOgU`c68zU28+Hm3fT{zV8PQhIya! z^?z^{6}s}2+h0C>hc+9j!b~q`VLolCzEi2Jh;RD=@Z4R;f$M6wzc`;Yc4s(OAm4BK zyfKwHUw5Io|AJ%FPDTE5P`GABs!#E*7mAloJ4js~?#|EQLJ(zXSolqNk^>rv=3hq8 zOe(6V2wK~?rD))b_;wc_BU=>M^XCb0nsa-QsaK93r!D4&=4=$PBCUgWoTzXpx0^^) zpNsWEcxZ}VZ{7AQvzi0T#I-+k`V_@X!jJy1S2!gu<5Dqh(PhX~I2vc9TE|w9EHXDL zASk#{_myw184`&ZVxj0+h05UImq@$&`k|FMsB@1rNG&OTjCz{@i0;=LNExlmz!4Ny zX!Pf5Y0?Sl(vB=J{P8!xid%=mCnp zWA1m0f1*73eXmVuaNFQAiS)+1RmBhB$KE{htX!W&n`ck7`+a-G%^F30B4u4FQkU)^ zq#ZCM6K;S*oa1yPhq-DHzY!)9XOxn_KM5X`>D{yZgk!&Fud15y87muZO^k|2_Umf0N1!L#4F}BW_nn!xk1*0bBRG^yIqop@6l;zTcd0l~pYCia!h>W_iqm zKe$wc?5XG+vr=mTxhG4#+;W8?pIn~CNkkY}>bdLpBm%XL&Ekb7xXbvX(C3-R)8nK6 zyuLAF4~BV)<#u8%iA)?{FQ zm&#bgsUD?0{Y0d?Xpkbd?7y91WqKM{PMN}he>#vlihl<6WO;~_^k@#q{bCC_+;PlBPNwnP0FKYTuABk_$25Ok1b1Ra70}?NScb5123Rzp~RA#3Tz1#Y+B);E0cK!f6 zwZWsx0u7$x`n|VvNKW-FrsPUr?Zbd#Kdn@VRivJZONq1+G3aX1l}4cd9U~KF{!yp> zG)v+|ZBpi8J%hXSkO1!UvlMs761jxPQJ%qrYX&?eWSQ7DQ;>%ip7rS`G10lGDjhSL z6z=)Q|9se6|Dt=QXk=V0qT1Vw;5ecpQ=^gf5$}rrAFjUop$)Cu^4`*xV#T3oi#rte z;;sRL26uNTR&H^30t9yn3GUM3?(VL^-3r5f@0)oulOOU2Bxj$q_gZVOHUEu8=pP2H zrPbyv?k)hOVbj1`G=BK@@(xF0fg~#Fo=fJXW>|&rNh>uu!F@;F2_Sq*cs{S(Eq<+q zy=nbrUobuWz&dnDw)7Uc>(VsUBXNyUVL5_rf&TP^l23PTHQd8^GvIjreevtUG9{+q z1eAwsf*QegT~$6KMuqBUB`gJZ4MD~89(I`QZ=M<`GZ!|6zI5V}wk$9(O0`c(6q_xt z&dB(`B!BoZC}tyU`42Z>n^l<1ZaTCuj#%bo88^CKrb#^T^|RP;Tf4Q2*lHNn>pe|?Mu>P&F#xA}uH_Xd!eqAo zwk$Jl)>UF3RW~mPc)z@^99cQ7zu5yP*}Az#g=p@EMm_p%%NN2P@EKgnhulExZ!)KP zZf4y>#H;2NB{FtpuOUf|zXhMIB-|k*sOgK{@e#T`X{J5joWpGGDX5BLTRI%9<-3NzLe8*~=|>Ww>~ogh$wKwygg`z6@JF#NZR3HiYPDrBTEv?DfdOkiDy9$L%|GJK9nxuD$guzPW%Db&Ua`07&OQMo(^U#FrQ+PDHUaKDY zdJ?T|0?un9?6a>9q+5uM3O>#z)dbiJ$FaR&-S=yKRQ3k${OPt5?On6aN#t*@Rl*U= z+(BESRx8c7&9JS-Jl03cyEv9JoUBXd1kDT*Io zM+sgvR}3$edEW*MUK}OQZp+FK_GG`B{;?A5`tyUqtRoEHtC#%H%(uMd2c)}~#F2y@ zRmeh*j9ajItv1%CCaSi0en~Uox)rILyt=TRa$9kljy?D+zZ~9v0c#tdagr#+AsMpc zZ5lu?nEpQUw>;Ti+0zsARo?Tx1{=T)ouDSQu~DO%pm_a~`w;&iy@Eq@@F4==R) zt|RH`d5lAtfqs%X;65J>D0`o>lPKW#lKA1ru(6FFbd~tW&D$yJ;_fSR-AuYH}vReCngwJ%5Zk0suC!R zn@32k>kOP!aXMI?LhW%y)pC9KtlayO#!qKu9rc=JAca21&w&$eOG>xwJy(h8n6_sMxG$x+f3AI%QN-2CromeFM7`}_Z`1;7?b1RlThKPyoG6L-|N z`9E;@y?f-ktPH|OlaSx@VSeO(uDE#-i3Nv2>KjiFxy3?H=q`h2xU#bP&nU*ZrU4aM z+cUwrq1~012A$D3$&vyodphN8M?cffKjNyXHrLP>rjO3caU#G16*T*^7La@7t zHq|b%(1o!=HD7Boa;;RlN(NodWO1s^#xXc-n^NMhJ|gRq_m~U7TeDv-;5t}w;*K+y_mb|Q z`LKIJal*u>gNA{IXbpRx`0S`^{p#)uq_HNBCM6x+Zm@HXn!jCD9E;{YdtC_YnUWN_ z`qsK85k>#^v9EM=KM>$6uHm6)o7Fc>l-0eDFdt?r9H#5mHJ^USVS(9^WkB(D9PUs_ zbyHWymm1Z#t+JWX=+c?kjZ=b`;}n$JFzunzvwgD( z*HOZW{oXb6h5d+nO=#fQ8Syglw={#e)^XM157$CF=WfaRf%iZo;dM( zvx_t@UHkGPYs4aq?=V;YDNXbpGqMG4wJs#m?~N-+-CB5@@T>`e~XRX3)4)!M=&% z(4x;AuqrSN0~?@G5cwhRi&9~DX3;-tg{38xC9fZ7o^8b!qvDjfw+X|iv7kPbb@;D6 zW5Y4Dw97ea&b9k`!8M2 z0A`EAS%+~xW5GKFLE5+R6}}dY?{pGX%HZ!4#O&<0%M80Z*^G%B_Hze?iH=Evz9RVz zG8vjb3sOFtLiVDZgCp@2)NN{It|l2NI!cZ>kytn;+x5a3=SQ#^l$=ln`P8WSf-^t) zYvPas2Dpiv``Es5%AIzn?eb52YN1hTwKzO7M))0Lt23*YXJT)(adjP&5x7gWS;3!C z{7lRxxTmSb90|NYm_e+q=|y>^S0MXfmVc}e@` zV0Z15R+h`g@8gFhvwVL6UCl=E%xeulK~G<2A1D+`D_6JNsHpONz|SS&27F*$(WqyK zMoAuj!sW?l16AhNPm>Uat!l0k#Cna|kzi}7AiFAQs;4Q6G#kKk%a|mFT?m`Rm$3K~ zqDoS!GGT3u@&%8*_%>dw{W@RP+*X#01~grn+|^I87>>?lx4iUd0R$H4wyy^jI5B$PS)8Gp#|=$AG2PWkv+i{%NOBckN=5rUK#bi_*2e+|YTO@bA_{#K?H2jH-$Xh``q{!dc7t5ON^ z9mZX<$9^#E*Wbq8t<6j5R}%C7(`LM>hkv;XmA6GNwfRIpEVv-kUnd?;?xY@OdpTTz^*P7Mu9|rGto;f9irI;r@BA|15x0g8K4S zlV7w6?BSn0Tt zHRY1fHJ3&nh;>2^lW34o)?*eJ6YC3?+e~SWbYPwOsyd>^A7WFX;U249m~Ecdepa>H z@!(i>568Yvd*^VS8K!vZ-TM!W8s$Y#(#|7BkW5RgQFXuwO$iW39Z_Q2+F?;v8_Z0l zU#}n;{jugNj-Hv0Y25oot&$ySuB)8q5WbQ3=7*`!&{>DGKxTHW`(ZvAZ3vqvDI;`IPl~tBC%{AB$vGD+j;2Z6c}{evwxdM*7qL;`pjbr^$^c%Hp<^jq|au|?mq4r8`gMl%%U&vB&C?STn?dva;a zb;R^r?EJbK!1S$$Iwj?arC~6tcgO-U)5VKK!0BwiZ}4leQjxB^V*5}v;XJkbNwy_? zuzp^nuxVS+UHoT>bunim)f(=ezwGOC>^$@|3w)FK8mgLN1U)0`J0(vGaI15F;N zxMxs&!qQ6KH3y}JkzzK|Os7$;R_ks#h5ou|r!_foEqof6r)1<`OO%VaWKgb*EOE;} z+I$o2QadxkDj(-i@C!<}f}?_*=|Yp;JQYc?Yy3!I5f4?YmYKw2rkt}H^O}WF10-oU zG0t4Dn4Uty&~NX?K??O}JmCyenq;QFX|+lqIN?fRT|GD=;=ZR&_rNGVtfct*B5n&m zGEA0tVufI^!G)>e!?)?l!jtoSu7#o|3H3IVaDSp%V#k1XUFwCRLfO~r_uGH2Uum2S z>)b2?U7L7K3+jCdxbrs-0R}0?5v`CkhfLR%P1zpWd*zg~`|A^~FW65@y*$_3W{w8m$O7jLAB-o*f-J{LkaOb>p>f&0Yc ztP@-dsk*Fq-{tl@Le@p311`~>ixF2fa#=nr1+vSG8a4e(tSHj1uVt+ABvo%omJ%29 zbb6+TB?As0jn7^FeY7lkUlTQESg!~u!yYO;aNR%Rir<2`Rc2WiUi+&wN&X= zv%DKMeb9||{F9+gu4UI6xf9Iv9L9NU5q#VAHSbB+@vCy5qGwL?rktvMQL|)%AY7%yMJA-ir zM$x-NO0lF`wG7YF>!&x_9Cd1Ku~Rju6Bb88+F``4=|F)`$%k;5>Fj+XtDWCXWK&lN zE?OvAetZ0};?waz-IlJ*v-5J^W#lmw|JLStJY@&NQTN?t{JLTd`gOBOQ_OWqNVxnc ziNTeuny*xMA368Jdlu5-#Oo~H1!^dusM#k2*3RLq)2mx7Aub7+lF%f^YX0?_<-Ezd z;Ns3Ppe5&Xw}^+-S#~uBb1$Hr0wtD28l>m^bpR%I2>L7)=LmM~6whd6BzOIZp8Z_~ z;q;B>RhLIuc!Gg(bSPYRbjUvUtkpaVX>J~}!8QzE?D90pZ6;z@3(Xl@kwGWwwIQ=o ztPibi_#1dNi{?qUOj$1#U~6RzF@`X_TD3#rnr37d-O;Y*vPC2aZ$uzzURf2fBVF;j z5To*FT)LH8v#8+OG8c`(kp&f*))o=v748`vmItb z9(ATvwMz40%4PF*us%0x=5CM;WK}aPEUZaO#Jt(6@JB+7UsSIRtt8j7ZO&j}#W zez=Z9C9<7p=?bXuS1RT0fw^f(fkA1ua6`iTC&&2G{?ZcnBW4w!z)7$}nj0t6ig)P6>>Kzdbx=$z z(uI=3fJ&EbKgl(z;Te;)bMpX7i_)!}0=YFVB(9p4i<3)ZODIBLL_iA!ZB~tH4~Pw0blC0vhn${i)?<# z0NBK!3->X6^-w~dXkXVzmt?`T*zLqoy z(@j+)l3Cg&DSNlj9<|P7>w%LstsvF3%1cAo;yOMd15RCsPzwET!c}+coY*x8)P3B9 zt$yBm(6^{t>~6q2)XF?#h6k|&5TUe)p5w+){tQ60l{d{ea4VK7x*l7cI7? zeC9Faw#}EQ8?yvvn>pjw)yAyge9TcpTs=)2j4bt&#{KML)!|*7(=EzZIHjg7yIb>3EHwf~=!gs&t1h@#AB(my;+O?Y%Fu5fy*f>f}Qn`?A z5fs~>zt|~c7B;vBrVOE4S{2vfIHwtS2FAeAHnw#P=El6>RWM5yM$wsgFU zD?CyuPoqz^F?@F75-4odMsK2fXn8A1FWH`JV~x|!WYMPJw?T#B?(V8apead{l3MbW z8H(6;ekaYRNJYcp&Mw4v2xdDyINZ$n7WFv6t1Q1CO*mLz0ntr8(gPIe*fp8HQ5w7s zLL#dO3}u5tj^FZ+AIUUEDY(nR)Pb3h%Cy1dchxamnnYorl3%wQSseUGihF;C|s(2FZIg zKn1;<#|Jqb;}Ar0+D;PI6o8y^g1PE0I!xth>KauF=8}FsE0?%4Xmm^u7bL5m*q~A5~(fzLHymVoVSD_c1Q{&p6~I>y%cfd>iHM;R)WVQ~MI5)}%Z! zhAl^+ltwOtX4o=`?!Jx4t@ij?uHjh+_rSO@xMZ28s7ev4AURs71dFh4dXpX55_;aX z{GuJe@|clgsghJlC~-V9#nv*CJHbqx0j40w{8+L)Nw2>o+d1&ULIl+u@3ChZ&)dV7}C2{x=@S=(JfNv z^)qWRh)Sk?JE@cAj~{D@RpVZPSM2b3Ynh(j|Hem+_5{RPCwWa7Z_I~7HaP%fbyK3H zic^_c-#rU0L1bW(*UT{6rf+9yJmJ4X!W@OIWCL#Yh)&Hi>D#s)$1gQC%mAq{HyHKzi?(YUWoo}nqO@H^Aa;t^HEMQIJj4b(G6e3 zs$xW!Y*O2U;<#2IJ46a@m9t+hmkc2^@4Y8uzl{BrGS6*R-$Q!Z(=Famvg2@F{#kr3 z**a;bnd!W2WK_1yQEFIY`$DxCV%kJd?LNi(T|}tFJXJTe`B2;1d7i_f)i%I8Dckq> zEq1KHh{FcGL6sI3b{qnLEVvk`8=hXU;4${*@tTi--=LJaLO|-Lno5fX`vt~$7qM^Q z?~Xyin*sPU8O~F>pyaS*=)D5p>FcYk4{f0AU`j4!nBuYweLQDE8NDjXH>W+`^N+2- zqj?JZjCt`>7Xraq1Y)ER5hje=*f_M#aJ0hfSI&!?8NI{_)1zyBOg5iQt#42&Oxx>* ziGED3G7nXCkHvgAwID8Rrj1AvREl*LB(JIH!f_ueN%QbD+Dx^z#R0oJOwLpqRq#qn z(1ofnU*^B!T3^Ltzm!{-z}aw5=9X~TX<|6k16d@o6Nhh%OyXp`=zJq&NE?&sDIIwC z?xE=oxh^V~aTV9e`Wy3GF43Hml63)MYYGBscK$sjrP+XHE9@ACT|Zcq(-lvi`DV3{ zStFswyQU(oD9*hA#Q3TDgo< z^iUftgJQ2q;E%I<&JiH5ad({`c9p1~J7D31eR-nwE8~r$H5CZ2BmXsT#;Y--?vpl{ z)F;e~ne=VMiHu3Rpw0Rgwv9F8#x=SA7Uek<4S(n+&(w^@I>4W%`blGG>QHmEZ61Q_2oKJn55m z^lm@d2R4ewHp%aNt(X`YIqc_|L4D1kZ{H?-2j_S2!P$H60B z2s72J%t}>#_fwAYBpwGK`@6K(^)iA`xE!z&>`-s`-9MhcIS6Ih%`Y=adm6|Z{aqqf zlz@^0bo?!O`@6VbC|!vQsC}S}AAnswqBdpB5#<)n1t4POqGJ#+%0CHte}Q}1&iojB z^35VY{-kn3n0BDJgZIMy z2+c#68r-IjZE2*b4U^Au3Vze=yUzdTKK5Hj*VR|%xb+bS_C;@*O(G5&DZQb2KFB_% zM|WsSlNXpoITU%LDzd!7H6(Pb$)xX?p~TT@)3lr0R;XiL;M2M4;okI^^w3Mks;nW! z$#PQy08=mCx^iT~6)_~tY)zx>D!va=M@n^f*Iid~Ec7mL)|Q~x_~!L#ngkvH>H zh=NICQw{Shl~He!;dB0;7+sM6Td_NH^`@R%Z({v$b-GvM8lRfXPjXm|@E|u$-0Xf7 zNV!I>Rw#eJ3k`5poy^0^2x}T6?&b8?{L5T z7g|-dmAD?pOo!MtW8WG%t2XQuOzZVpkT50!^79bwf*!pF{&hAgt*Vbf1I{;od{0Yj z^J3iI9{!O(FJY@S`^;7LvP5^3@H}mUT*1Yxzq0>JVyCml+%lKcdn+5QLYI|1m0<08 z{vWd(#=maLw{XS7ArP-gmwPw|#Sz8SB}Zmb4$^6fy;g;wM)TwKDx-6Y=jy z$O6z`+#oFt459RL^ASDu-lwobldrxQES&n+fWGXn^)Gi>^u<~>+%`!5FB_1{-? zRes_+SFo~M`YiZSRv~tYS0knDP8HJ?rdTbdz}XKNt?nNQQBao>0lHtdeQ?2W%M!+? zn0s@Zp60|I9sZiZ7H-amZJ1tA9M-5eeC72{e0=t+4uxWFFn@c){HNGE>^0r>>TWtJ zo)(3doXaS^UF5zH>&>v4Z*I-ZRL7^|aRALR{#|WS+7UG2(#(ps4aQTwlOWWGq2o}K z%S>jaVKw#^Io>&<4lBfGWmNrp8w8iA5=#_XfpFT}m#(U64Gv{Al0QB;Lh{YDs7a75 z%ATKZExo%)y!BqKQJO$J)k51mnEVvuNiJqg?4xZPF*8>=emF^YA{6lYr4HhdF~O#&}9Oy}27$B15@rR~oUMl~5hs^RN)9uoonsVW|-5gdiYlgUtmCeiq+ zMfwVc+i}4s@TO+k2}E*;xHPRyleogOlcG@(vg#m*zMMB%%RcJu9Jyj2;9df+vppuG z#%9w#Q9B?=w^6lv1iq#-2`3g1EQxExRQTBj#l{sk{Cbg*Ic$6&pIZ48s(~f7-8kk$ z-oPM-2e43K`D3-M*%{Qx%o#n`bD$?hZaLsteYsrOL)ql!o^guy5(kqleJ}lKBmXb6 z+W)Nu5YWHurp$yO$Vn zw5c1NX$%YFx{tYTgoHP%LVi9MIhWLc7Y+ULk8-E6EEhx$1d7sO-Chw=l77dQePK-_ z$CEuhN=|v+;VP$jiTRmC?Yt|@bp0r&=4mIA$pr|rjBLr*7`aF45`$qJLz_*py``~* zUSy1en?7l_mT}tTWf(NNtV!ZCG~T#gJ-~kY4IPsPtJVIe!_2znvRLl0Gz1pVTpO0) z!Zx=?*J0|rXJLzrwpAT90n&hk;*7R&21ZJQL)~h78zsrO;bm;gJJ(K6*v|Y8PD;Vn zm;Rw;-rs-YV82op{omv(`_Ydjl5V`m1lTl=dB68IF%HcQeOVhgt9nBy*p1v?wd@pF zE(MzY&KttWZywSJEPBi+{HX>wEh{uI+2>ZZG^Zflmf%EJ-(+v0J33!y;3my(JZ&;K ztMiy0oKOBDeU%xfGN6 z)5k=l)#D_gRB1TbAfv&toblSrIqRI9wdu$W#QdV{t8rlO4ev$RDhR@}c%j7898vRown(x$;M#h6-2 z{mPs4e@$IS^VEF1e=DHJ!GFtHEYXPR{&Fo0rPJm z$K=xyFyC(l)C^cZm(0Ad?tmFB35+Eztdw zv1xjn`d+CWv67+@&%(K~#wBh~cnFQb;XiTto+mZu5pNAv;8aiw4K4nSv*orVo)Z@} zx6&GI+i-@-QZn>)!Hms*!zj}@LMJ3ttjBS0YN>%%yM3m?%xj3u!F8MGCT_=>v$YYZ zstDnC;GQzX1;An= z{u=;`Q3%LqMC>tb!Ri&&abG3UK2SS<^<#q$0{ixZlNue&Yj=(EVTPhmUB_mi0K_El z;-4)F8{7C)JSAM$GM(`GZN*C=^XkItT37#sh_4R3b4~1@%7NU!e~hY{{BtrWsd3Be zxDeNPsIz!b@*~xt0t$=oAPh*o)tK$K;RF%a#<6owyMRiH&Ak92uFE@b9-DjfJ36j@ zIm7%f($tgtT~m}NF(!K6W80m%27GAWqEDBy|0>UB8|M&`sPR$er8-c9bBV$xh~-D@ z+gq-gIe}NF?3n(`ob+-DFhz$I>yU&%SaYWtv_PjgxuU1jU7&w;wdxalp@40o8}c)m zNv|#Jult8Xge5kSv;bc>Q0C9J&T)Y2nF*SHnKn&+U8P)U@J&MgFTrlbx&?FN3K4B^ zj8;P)JZdhXIdRhjp)}J{2)#-4<&}FzHA}xI2s32tZ zrY(P)2|#Z99IlXYN{}v@R90GZ>h6)sZF0D8lJ}U2&v>n~+tQg4dq zD77)OsVMPw63_-cBAG4iaGSv0jv&PzM$j92XznG8IFMdqnO@wSMda5sA2tIxk*R0a zFO(`}4S#)MEK8K*X_{eI)?gm{iY)j|&a-LQ=}%AK_=dIn9~QS(^H&AqUr?4>$wblb z@*~c*HpU3zlZhUA%*K%v0;^|3mk&h=0JthGd|d4@!fOiqXOJ&7K`ZwUnhv6($H+iC zP1dtJ?oqxjQ6=uPJR}L0>svV|Ed<7z_(%4MV9G)0vg!U6E$N4_`bY__G}mImENr9)out7~gv*t|-~A z{9Gh8>BwIO#>6;JSZf~Cl@?h0MM~;+P*HY)T!t_&^Xhl23G~Z#nq%^r7a5kDJW;3Z zqjn@Y4TT*J>u9!&A3WG`BLGDZ>#R#&Dm>`kK+aI?Q2lVz$J+q5ZmHL$m~2Z@v!;@; zxjI+y#18;vUiIgdFzhmiE_SLVRMY75pg{c6)3YR%{}Go6GNQzI{Z{)^Pzoz7J5f$G zO>XQUVpS;{leQaQc$@x|SLNteF@1cv|4G&~i+uD0;}92u%rWaw4!5A|=(s%SJ?Dy< z^N3`tMSe}gyb4`nK-Skaj1Y#Z`J*ow+K#RD_=>p?)(L0VD^>waIMB6A^r_YNK#-}O zI8sxu&!{YOy52rAp>|HuN*kTX0H=`;)tIV&d5`%0D_NSM(B+)eM|7D^O4;RR8DxEJ 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

WGE9 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

vIR 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�oo}s|Bhl)S$ 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%0D;1Bm1Moj{-uM3<#v8 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 Date: Thu, 30 Jan 2025 12:32:16 +0400 Subject: [PATCH 212/212] Add changelog --- changelog.d/vips-blurhash.fix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/vips-blurhash.fix diff --git a/changelog.d/vips-blurhash.fix b/changelog.d/vips-blurhash.fix new file mode 100644 index 000000000..9e8951b15 --- /dev/null +++ b/changelog.d/vips-blurhash.fix @@ -0,0 +1 @@ +Fix blurhash generation crashes