From 6a6c4d134b7564012d00e89f1236b904261ab5db Mon Sep 17 00:00:00 2001 From: Sachin Joshi Date: Fri, 12 Jul 2019 21:02:55 +0545 Subject: [PATCH 01/25] preserve the original path/filename (no encoding/decoding) for proxy --- lib/pleroma/web/media_proxy/controller.ex | 7 +------ test/media_proxy_test.exs | 16 ++++------------ 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/web/media_proxy/controller.ex b/lib/pleroma/web/media_proxy/controller.ex index c0552d89f..ea33d7685 100644 --- a/lib/pleroma/web/media_proxy/controller.ex +++ b/lib/pleroma/web/media_proxy/controller.ex @@ -28,12 +28,7 @@ def remote(conn, %{"sig" => sig64, "url" => url64} = params) do end def filename_matches(has_filename, path, url) do - filename = - url - |> MediaProxy.filename() - |> URI.decode() - - path = URI.decode(path) + filename = url |> MediaProxy.filename() if has_filename && filename && Path.basename(path) != filename do {:wrong_filename, filename} diff --git a/test/media_proxy_test.exs b/test/media_proxy_test.exs index 1d6d170b7..fbf200931 100644 --- a/test/media_proxy_test.exs +++ b/test/media_proxy_test.exs @@ -88,10 +88,10 @@ test "validates signature" do assert decode_url(sig, base64) == {:error, :invalid_signature} end - test "filename_matches matches url encoded paths" do + test "filename_matches preserves the encoded or decoded path" do assert MediaProxyController.filename_matches( true, - "/Hello%20world.jpg", + "/Hello world.jpg", "http://pleroma.social/Hello world.jpg" ) == :ok @@ -100,19 +100,11 @@ test "filename_matches matches url encoded paths" do "/Hello%20world.jpg", "http://pleroma.social/Hello%20world.jpg" ) == :ok - end - - test "filename_matches matches non-url encoded paths" do - assert MediaProxyController.filename_matches( - true, - "/Hello world.jpg", - "http://pleroma.social/Hello%20world.jpg" - ) == :ok assert MediaProxyController.filename_matches( true, - "/Hello world.jpg", - "http://pleroma.social/Hello world.jpg" + "/my%2Flong%2Furl%2F2019%2F07%2FS.jpg", + "http://pleroma.social/my%2Flong%2Furl%2F2019%2F07%2FS.jpg" ) == :ok end From 27ed260eed51798a20608b1c062d32bfc7b6cdc4 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 12 Jul 2019 18:36:07 +0300 Subject: [PATCH 02/25] AP user view: Add a test for hiding totalItems in following/followers --- .../web/activity_pub/views/user_view_test.exs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/web/activity_pub/views/user_view_test.exs b/test/web/activity_pub/views/user_view_test.exs index 969860c4c..86254117f 100644 --- a/test/web/activity_pub/views/user_view_test.exs +++ b/test/web/activity_pub/views/user_view_test.exs @@ -8,6 +8,7 @@ defmodule Pleroma.Web.ActivityPub.UserViewTest do alias Pleroma.User alias Pleroma.Web.ActivityPub.UserView + alias Pleroma.Web.CommonAPI test "Renders a user, including the public key" do user = insert(:user) @@ -82,4 +83,28 @@ test "instance users do not expose oAuth endpoints" do refute result["endpoints"]["oauthTokenEndpoint"] end end + + describe "followers" do + test "sets totalItems to zero when followers are hidden" do + user = insert(:user) + other_user = insert(:user) + {:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user) + assert %{"totalItems" => 1} = UserView.render("followers.json", %{user: user}) + info = Map.put(user.info, :hide_followers, true) + user = Map.put(user, :info, info) + assert %{"totalItems" => 0} = UserView.render("followers.json", %{user: user}) + end + end + + describe "following" do + test "sets totalItems to zero when follows are hidden" do + user = insert(:user) + other_user = insert(:user) + {:ok, user, _other_user, _activity} = CommonAPI.follow(user, other_user) + assert %{"totalItems" => 1} = UserView.render("following.json", %{user: user}) + info = Map.put(user.info, :hide_follows, true) + user = Map.put(user, :info, info) + assert %{"totalItems" => 0} = UserView.render("following.json", %{user: user}) + end + end end From 360e4cdaa2708d54903765c61afbc5ea5f1b2cdb Mon Sep 17 00:00:00 2001 From: Mark Felder Date: Fri, 12 Jul 2019 11:25:58 -0500 Subject: [PATCH 03/25] Move these to pleroma namespace in Mastodon API --- docs/api/differences_in_mastoapi_responses.md | 8 -------- docs/api/pleroma_api.md | 7 +++++++ lib/pleroma/web/router.ex | 8 ++++---- .../mastodon_api/mastodon_api_controller_test.exs | 12 ++++++------ 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/docs/api/differences_in_mastoapi_responses.md b/docs/api/differences_in_mastoapi_responses.md index 2cbe1458d..3ee7115cf 100644 --- a/docs/api/differences_in_mastoapi_responses.md +++ b/docs/api/differences_in_mastoapi_responses.md @@ -46,14 +46,6 @@ Has these additional fields under the `pleroma` object: - `settings_store`: A generic map of settings for frontends. Opaque to the backend. Only returned in `verify_credentials` and `update_credentials` - `chat_token`: The token needed for Pleroma chat. Only returned in `verify_credentials` -### Extensions for PleromaFE - -These endpoints added for controlling PleromaFE features over the Mastodon API - -- PATCH `/api/v1/accounts/update_avatar`: Set/clear user avatar image -- PATCH `/api/v1/accounts/update_banner`: Set/clear user banner image -- PATCH `/api/v1/accounts/update_background`: Set/clear user background image - ### Source Has these additional fields under the `pleroma` object: diff --git a/docs/api/pleroma_api.md b/docs/api/pleroma_api.md index edc62727a..d83ebd734 100644 --- a/docs/api/pleroma_api.md +++ b/docs/api/pleroma_api.md @@ -238,6 +238,13 @@ See [Admin-API](Admin-API.md) ] ``` +## `/api/v1/pleroma/accounts/update_*` +### Set and clear account avatar, banner, and background + +- PATCH `/api/v1/pleroma/accounts/update_avatar`: Set/clear user avatar image +- PATCH `/api/v1/pleroma/accounts/update_banner`: Set/clear user banner image +- PATCH `/api/v1/pleroma/accounts/update_background`: Set/clear user background image + ## `/api/v1/pleroma/mascot` ### Gets user mascot image * Method `GET` diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index d53fa8a35..a3b6ea366 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -322,10 +322,6 @@ defmodule Pleroma.Web.Router do patch("/accounts/update_credentials", MastodonAPIController, :update_credentials) - patch("/accounts/update_avatar", MastodonAPIController, :update_avatar) - patch("/accounts/update_banner", MastodonAPIController, :update_banner) - patch("/accounts/update_background", MastodonAPIController, :update_background) - post("/statuses", MastodonAPIController, :post_status) delete("/statuses/:id", MastodonAPIController, :delete_status) @@ -360,6 +356,10 @@ defmodule Pleroma.Web.Router do put("/filters/:id", MastodonAPIController, :update_filter) delete("/filters/:id", MastodonAPIController, :delete_filter) + patch("/pleroma/accounts/update_avatar", MastodonAPIController, :update_avatar) + patch("/pleroma/accounts/update_banner", MastodonAPIController, :update_banner) + patch("/pleroma/accounts/update_background", MastodonAPIController, :update_background) + get("/pleroma/mascot", MastodonAPIController, :get_mascot) put("/pleroma/mascot", MastodonAPIController, :set_mascot) diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index 8afb1497b..b7c050dbf 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -593,7 +593,7 @@ test "user avatar can be set", %{conn: conn} do conn = conn |> assign(:user, user) - |> patch("/api/v1/accounts/update_avatar", %{img: avatar_image}) + |> patch("/api/v1/pleroma/accounts/update_avatar", %{img: avatar_image}) user = refresh_record(user) @@ -618,7 +618,7 @@ test "user avatar can be reset", %{conn: conn} do conn = conn |> assign(:user, user) - |> patch("/api/v1/accounts/update_avatar", %{img: ""}) + |> patch("/api/v1/pleroma/accounts/update_avatar", %{img: ""}) user = User.get_cached_by_id(user.id) @@ -633,7 +633,7 @@ test "can set profile banner", %{conn: conn} do conn = conn |> assign(:user, user) - |> patch("/api/v1/accounts/update_banner", %{"banner" => @image}) + |> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => @image}) user = refresh_record(user) assert user.info.banner["type"] == "Image" @@ -647,7 +647,7 @@ test "can reset profile banner", %{conn: conn} do conn = conn |> assign(:user, user) - |> patch("/api/v1/accounts/update_banner", %{"banner" => ""}) + |> patch("/api/v1/pleroma/accounts/update_banner", %{"banner" => ""}) user = refresh_record(user) assert user.info.banner == %{} @@ -661,7 +661,7 @@ test "background image can be set", %{conn: conn} do conn = conn |> assign(:user, user) - |> patch("/api/v1/accounts/update_background", %{"img" => @image}) + |> patch("/api/v1/pleroma/accounts/update_background", %{"img" => @image}) user = refresh_record(user) assert user.info.background["type"] == "Image" @@ -674,7 +674,7 @@ test "background image can be reset", %{conn: conn} do conn = conn |> assign(:user, user) - |> patch("/api/v1/accounts/update_background", %{"img" => ""}) + |> patch("/api/v1/pleroma/accounts/update_background", %{"img" => ""}) user = refresh_record(user) assert user.info.background == %{} From 1f6ac7680d1ae07be7c7dfd81a8cec2ba52f1c82 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 12 Jul 2019 19:41:05 +0300 Subject: [PATCH 04/25] ActivityPub User view: Following/Followers refactoring - Render the collection items if the user requesting == the user rendered - Do not render the first page if hide_{followers,follows} is set, just give the URI to it --- .../web/activity_pub/views/user_view.ex | 39 +++++++++++++------ .../activity_pub_controller_test.exs | 10 ++--- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index 327e0e05b..d9c1bcb2c 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -98,29 +98,31 @@ def render("user.json", %{user: user}) do |> Map.merge(Utils.make_json_ld_header()) end - def render("following.json", %{user: user, page: page}) do + def render("following.json", %{user: user, page: page} = opts) do + showing = (opts[:for] && opts[:for] == user) || !user.info.hide_follows query = User.get_friends_query(user) query = from(user in query, select: [:ap_id]) following = Repo.all(query) total = - if !user.info.hide_follows do + if showing do length(following) else 0 end - collection(following, "#{user.ap_id}/following", page, !user.info.hide_follows, total) + collection(following, "#{user.ap_id}/following", page, showing, total) |> Map.merge(Utils.make_json_ld_header()) end - def render("following.json", %{user: user}) do + def render("following.json", %{user: user} = opts) do + showing = (opts[:for] && opts[:for] == user) || !user.info.hide_follows query = User.get_friends_query(user) query = from(user in query, select: [:ap_id]) following = Repo.all(query) total = - if !user.info.hide_follows do + if showing do length(following) else 0 @@ -130,34 +132,43 @@ def render("following.json", %{user: user}) do "id" => "#{user.ap_id}/following", "type" => "OrderedCollection", "totalItems" => total, - "first" => collection(following, "#{user.ap_id}/following", 1, !user.info.hide_follows) + "first" => + if showing do + collection(following, "#{user.ap_id}/following", 1, !user.info.hide_follows) + else + "#{user.ap_id}/following?page=1" + end } |> Map.merge(Utils.make_json_ld_header()) end - def render("followers.json", %{user: user, page: page}) do + def render("followers.json", %{user: user, page: page} = opts) do + showing = (opts[:for] && opts[:for] == user) || !user.info.hide_followers + query = User.get_followers_query(user) query = from(user in query, select: [:ap_id]) followers = Repo.all(query) total = - if !user.info.hide_followers do + if showing do length(followers) else 0 end - collection(followers, "#{user.ap_id}/followers", page, !user.info.hide_followers, total) + collection(followers, "#{user.ap_id}/followers", page, showing, total) |> Map.merge(Utils.make_json_ld_header()) end - def render("followers.json", %{user: user}) do + def render("followers.json", %{user: user} = opts) do + showing = (opts[:for] && opts[:for] == user) || !user.info.hide_followers + query = User.get_followers_query(user) query = from(user in query, select: [:ap_id]) followers = Repo.all(query) total = - if !user.info.hide_followers do + if showing do length(followers) else 0 @@ -168,7 +179,11 @@ def render("followers.json", %{user: user}) do "type" => "OrderedCollection", "totalItems" => total, "first" => - collection(followers, "#{user.ap_id}/followers", 1, !user.info.hide_followers, total) + if showing do + collection(followers, "#{user.ap_id}/followers", 1, showing, total) + else + "#{user.ap_id}/followers?page=1" + end } |> Map.merge(Utils.make_json_ld_header()) end diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index 1f8eb9d71..cbc25bcdb 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -551,7 +551,7 @@ test "it returns the followers in a collection", %{conn: conn} do assert result["first"]["orderedItems"] == [user.ap_id] end - test "it returns returns empty if the user has 'hide_followers' set", %{conn: conn} do + test "it returns returns a uri if the user has 'hide_followers' set", %{conn: conn} do user = insert(:user) user_two = insert(:user, %{info: %{hide_followers: true}}) User.follow(user, user_two) @@ -561,8 +561,7 @@ test "it returns returns empty if the user has 'hide_followers' set", %{conn: co |> get("/users/#{user_two.nickname}/followers") |> json_response(200) - assert result["first"]["orderedItems"] == [] - assert result["totalItems"] == 0 + assert is_binary(result["first"]) end test "it works for more than 10 users", %{conn: conn} do @@ -606,7 +605,7 @@ test "it returns the following in a collection", %{conn: conn} do assert result["first"]["orderedItems"] == [user_two.ap_id] end - test "it returns returns empty if the user has 'hide_follows' set", %{conn: conn} do + test "it returns a uri if the user has 'hide_follows' set", %{conn: conn} do user = insert(:user, %{info: %{hide_follows: true}}) user_two = insert(:user) User.follow(user, user_two) @@ -616,8 +615,7 @@ test "it returns returns empty if the user has 'hide_follows' set", %{conn: conn |> get("/users/#{user.nickname}/following") |> json_response(200) - assert result["first"]["orderedItems"] == [] - assert result["totalItems"] == 0 + assert is_binary(result["first"]) end test "it works for more than 10 users", %{conn: conn} do From 92055941bd55cb75ab7b5a26d03918390e64b754 Mon Sep 17 00:00:00 2001 From: Maksim Date: Fri, 12 Jul 2019 16:42:54 +0000 Subject: [PATCH 05/25] Pleroma.Web.Metadata - tests --- lib/pleroma/web/metadata/opengraph.ex | 17 +--- lib/pleroma/web/metadata/twitter_card.ex | 47 ++++----- lib/pleroma/web/metadata/utils.ex | 7 ++ test/web/metadata/player_view_test.exs | 33 ++++++ test/web/metadata/twitter_card_test.exs | 123 +++++++++++++++++++++++ 5 files changed, 185 insertions(+), 42 deletions(-) create mode 100644 test/web/metadata/player_view_test.exs create mode 100644 test/web/metadata/twitter_card_test.exs diff --git a/lib/pleroma/web/metadata/opengraph.ex b/lib/pleroma/web/metadata/opengraph.ex index 4033ec38f..e7fa7f408 100644 --- a/lib/pleroma/web/metadata/opengraph.ex +++ b/lib/pleroma/web/metadata/opengraph.ex @@ -9,6 +9,7 @@ defmodule Pleroma.Web.Metadata.Providers.OpenGraph do alias Pleroma.Web.Metadata.Utils @behaviour Provider + @media_types ["image", "audio", "video"] @impl Provider def build_tags(%{ @@ -81,26 +82,19 @@ defp build_attachments(%{data: %{"attachment" => attachments}}) do Enum.reduce(attachments, [], fn attachment, acc -> rendered_tags = Enum.reduce(attachment["url"], [], fn url, acc -> - media_type = - Enum.find(["image", "audio", "video"], fn media_type -> - String.starts_with?(url["mediaType"], media_type) - end) - # TODO: Add additional properties to objects when we have the data available. # Also, Whatsapp only wants JPEG or PNGs. It seems that if we add a second og:image # object when a Video or GIF is attached it will display that in Whatsapp Rich Preview. - case media_type do + case Utils.fetch_media_type(@media_types, url["mediaType"]) do "audio" -> [ - {:meta, - [property: "og:" <> media_type, content: Utils.attachment_url(url["href"])], []} + {:meta, [property: "og:audio", content: Utils.attachment_url(url["href"])], []} | acc ] "image" -> [ - {:meta, - [property: "og:" <> media_type, content: Utils.attachment_url(url["href"])], []}, + {:meta, [property: "og:image", content: Utils.attachment_url(url["href"])], []}, {:meta, [property: "og:image:width", content: 150], []}, {:meta, [property: "og:image:height", content: 150], []} | acc @@ -108,8 +102,7 @@ defp build_attachments(%{data: %{"attachment" => attachments}}) do "video" -> [ - {:meta, - [property: "og:" <> media_type, content: Utils.attachment_url(url["href"])], []} + {:meta, [property: "og:video", content: Utils.attachment_url(url["href"])], []} | acc ] diff --git a/lib/pleroma/web/metadata/twitter_card.ex b/lib/pleroma/web/metadata/twitter_card.ex index 8dd01e0d5..d6a6049b3 100644 --- a/lib/pleroma/web/metadata/twitter_card.ex +++ b/lib/pleroma/web/metadata/twitter_card.ex @@ -1,4 +1,5 @@ # Pleroma: A lightweight social networking server + # Copyright © 2017-2019 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only @@ -9,13 +10,10 @@ defmodule Pleroma.Web.Metadata.Providers.TwitterCard do alias Pleroma.Web.Metadata.Utils @behaviour Provider + @media_types ["image", "audio", "video"] @impl Provider - def build_tags(%{ - activity_id: id, - object: object, - user: user - }) do + def build_tags(%{activity_id: id, object: object, user: user}) do attachments = build_attachments(id, object) scrubbed_content = Utils.scrub_html_and_truncate(object) # Zero width space @@ -27,21 +25,12 @@ def build_tags(%{ end [ - {:meta, - [ - property: "twitter:title", - content: Utils.user_name_string(user) - ], []}, - {:meta, - [ - property: "twitter:description", - content: content - ], []} + title_tag(user), + {:meta, [property: "twitter:description", content: content], []} ] ++ if attachments == [] or Metadata.activity_nsfw?(object) do [ - {:meta, - [property: "twitter:image", content: Utils.attachment_url(User.avatar_url(user))], []}, + image_tag(user), {:meta, [property: "twitter:card", content: "summary_large_image"], []} ] else @@ -53,30 +42,28 @@ def build_tags(%{ def build_tags(%{user: user}) do with truncated_bio = Utils.scrub_html_and_truncate(user.bio || "") do [ - {:meta, - [ - property: "twitter:title", - content: Utils.user_name_string(user) - ], []}, + title_tag(user), {:meta, [property: "twitter:description", content: truncated_bio], []}, - {:meta, [property: "twitter:image", content: Utils.attachment_url(User.avatar_url(user))], - []}, + image_tag(user), {:meta, [property: "twitter:card", content: "summary"], []} ] end end + defp title_tag(user) do + {:meta, [property: "twitter:title", content: Utils.user_name_string(user)], []} + end + + def image_tag(user) do + {:meta, [property: "twitter:image", content: Utils.attachment_url(User.avatar_url(user))], []} + end + defp build_attachments(id, %{data: %{"attachment" => attachments}}) do Enum.reduce(attachments, [], fn attachment, acc -> rendered_tags = Enum.reduce(attachment["url"], [], fn url, acc -> - media_type = - Enum.find(["image", "audio", "video"], fn media_type -> - String.starts_with?(url["mediaType"], media_type) - end) - # TODO: Add additional properties to objects when we have the data available. - case media_type do + case Utils.fetch_media_type(@media_types, url["mediaType"]) do "audio" -> [ {:meta, [property: "twitter:card", content: "player"], []}, diff --git a/lib/pleroma/web/metadata/utils.ex b/lib/pleroma/web/metadata/utils.ex index 58385a3d1..720bd4519 100644 --- a/lib/pleroma/web/metadata/utils.ex +++ b/lib/pleroma/web/metadata/utils.ex @@ -39,4 +39,11 @@ def user_name_string(user) do "(@#{user.nickname})" end end + + @spec fetch_media_type(list(String.t()), String.t()) :: String.t() | nil + def fetch_media_type(supported_types, media_type) do + Enum.find(supported_types, fn support_type -> + String.starts_with?(media_type, support_type) + end) + end end diff --git a/test/web/metadata/player_view_test.exs b/test/web/metadata/player_view_test.exs new file mode 100644 index 000000000..742b0ed8b --- /dev/null +++ b/test/web/metadata/player_view_test.exs @@ -0,0 +1,33 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.PlayerViewTest do + use Pleroma.DataCase + + alias Pleroma.Web.Metadata.PlayerView + + test "it renders audio tag" do + res = + PlayerView.render( + "player.html", + %{"mediaType" => "audio", "href" => "test-href"} + ) + |> Phoenix.HTML.safe_to_string() + + assert res == + "" + end + + test "it renders videos tag" do + res = + PlayerView.render( + "player.html", + %{"mediaType" => "video", "href" => "test-href"} + ) + |> Phoenix.HTML.safe_to_string() + + assert res == + "" + end +end diff --git a/test/web/metadata/twitter_card_test.exs b/test/web/metadata/twitter_card_test.exs new file mode 100644 index 000000000..0814006d2 --- /dev/null +++ b/test/web/metadata/twitter_card_test.exs @@ -0,0 +1,123 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Metadata.Providers.TwitterCardTest do + use Pleroma.DataCase + import Pleroma.Factory + + alias Pleroma.User + alias Pleroma.Web.CommonAPI + alias Pleroma.Web.Endpoint + alias Pleroma.Web.Metadata.Providers.TwitterCard + alias Pleroma.Web.Metadata.Utils + alias Pleroma.Web.Router + + test "it renders twitter card for user info" do + user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994") + avatar_url = Utils.attachment_url(User.avatar_url(user)) + res = TwitterCard.build_tags(%{user: user}) + + assert res == [ + {:meta, [property: "twitter:title", content: Utils.user_name_string(user)], []}, + {:meta, [property: "twitter:description", content: "born 19 March 1994"], []}, + {:meta, [property: "twitter:image", content: avatar_url], []}, + {:meta, [property: "twitter:card", content: "summary"], []} + ] + end + + test "it does not render attachments if post is nsfw" do + Pleroma.Config.put([Pleroma.Web.Metadata, :unfurl_nsfw], false) + user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994") + {:ok, activity} = CommonAPI.post(user, %{"status" => "HI"}) + + note = + insert(:note, %{ + data: %{ + "actor" => user.ap_id, + "tag" => [], + "id" => "https://pleroma.gov/objects/whatever", + "content" => "pleroma in a nutshell", + "sensitive" => true, + "attachment" => [ + %{ + "url" => [%{"mediaType" => "image/png", "href" => "https://pleroma.gov/tenshi.png"}] + }, + %{ + "url" => [ + %{ + "mediaType" => "application/octet-stream", + "href" => "https://pleroma.gov/fqa/badapple.sfc" + } + ] + }, + %{ + "url" => [ + %{"mediaType" => "video/webm", "href" => "https://pleroma.gov/about/juche.webm"} + ] + } + ] + } + }) + + result = TwitterCard.build_tags(%{object: note, user: user, activity_id: activity.id}) + + assert [ + {:meta, [property: "twitter:title", content: Utils.user_name_string(user)], []}, + {:meta, [property: "twitter:description", content: "“pleroma in a nutshell”"], []}, + {:meta, [property: "twitter:image", content: "http://localhost:4001/images/avi.png"], + []}, + {:meta, [property: "twitter:card", content: "summary_large_image"], []} + ] == result + end + + test "it renders supported types of attachments and skips unknown types" do + user = insert(:user, name: "Jimmy Hendriks", bio: "born 19 March 1994") + {:ok, activity} = CommonAPI.post(user, %{"status" => "HI"}) + + note = + insert(:note, %{ + data: %{ + "actor" => user.ap_id, + "tag" => [], + "id" => "https://pleroma.gov/objects/whatever", + "content" => "pleroma in a nutshell", + "attachment" => [ + %{ + "url" => [%{"mediaType" => "image/png", "href" => "https://pleroma.gov/tenshi.png"}] + }, + %{ + "url" => [ + %{ + "mediaType" => "application/octet-stream", + "href" => "https://pleroma.gov/fqa/badapple.sfc" + } + ] + }, + %{ + "url" => [ + %{"mediaType" => "video/webm", "href" => "https://pleroma.gov/about/juche.webm"} + ] + } + ] + } + }) + + result = TwitterCard.build_tags(%{object: note, user: user, activity_id: activity.id}) + + assert [ + {:meta, [property: "twitter:title", content: Utils.user_name_string(user)], []}, + {:meta, [property: "twitter:description", content: "“pleroma in a nutshell”"], []}, + {:meta, [property: "twitter:card", content: "summary_large_image"], []}, + {:meta, [property: "twitter:player", content: "https://pleroma.gov/tenshi.png"], []}, + {:meta, [property: "twitter:card", content: "player"], []}, + {:meta, + [ + property: "twitter:player", + content: Router.Helpers.o_status_url(Endpoint, :notice_player, activity.id) + ], []}, + {:meta, [property: "twitter:player:width", content: "480"], []}, + {:meta, [property: "twitter:player:height", content: "480"], []} + ] == result + end +end From 97b79efbcd4ad829a575019f842e7dcd7548266a Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 12 Jul 2019 20:54:20 +0300 Subject: [PATCH 06/25] ActivityPub Controller: Actually pass for_user to following/followers views and give 403 errors when trying to request hidden follower pages when unauthenticated --- .../activity_pub/activity_pub_controller.ex | 51 +++++++++++++---- lib/pleroma/web/router.ex | 8 ++- .../activity_pub_controller_test.exs | 57 +++++++++++++++++++ 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index cf5176201..e2af4ad1a 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -103,43 +103,57 @@ def activity(conn, %{"uuid" => uuid}) do end end - def following(conn, %{"nickname" => nickname, "page" => page}) do + def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do with %User{} = user <- User.get_cached_by_nickname(nickname), - {:ok, user} <- User.ensure_keys_present(user) do + {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user), + {:show_follows, true} <- + {:show_follows, (for_user && for_user == user) || !user.info.hide_follows} do {page, _} = Integer.parse(page) conn |> put_resp_header("content-type", "application/activity+json") - |> json(UserView.render("following.json", %{user: user, page: page})) + |> json(UserView.render("following.json", %{user: user, page: page, for: for_user})) + else + {:show_follows, _} -> + conn + |> put_resp_header("content-type", "application/activity+json") + |> send_resp(403, "") end end - def following(conn, %{"nickname" => nickname}) do + def following(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) do with %User{} = user <- User.get_cached_by_nickname(nickname), - {:ok, user} <- User.ensure_keys_present(user) do + {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do conn |> put_resp_header("content-type", "application/activity+json") - |> json(UserView.render("following.json", %{user: user})) + |> json(UserView.render("following.json", %{user: user, for: for_user})) end end - def followers(conn, %{"nickname" => nickname, "page" => page}) do + def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname, "page" => page}) do with %User{} = user <- User.get_cached_by_nickname(nickname), - {:ok, user} <- User.ensure_keys_present(user) do + {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user), + {:show_followers, true} <- + {:show_followers, (for_user && for_user == user) || !user.info.hide_followers} do {page, _} = Integer.parse(page) conn |> put_resp_header("content-type", "application/activity+json") - |> json(UserView.render("followers.json", %{user: user, page: page})) + |> json(UserView.render("followers.json", %{user: user, page: page, for: for_user})) + else + {:show_followers, _} -> + conn + |> put_resp_header("content-type", "application/activity+json") + |> send_resp(403, "") end end - def followers(conn, %{"nickname" => nickname}) do + def followers(%{assigns: %{user: for_user}} = conn, %{"nickname" => nickname}) do with %User{} = user <- User.get_cached_by_nickname(nickname), - {:ok, user} <- User.ensure_keys_present(user) do + {user, for_user} <- ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do conn |> put_resp_header("content-type", "application/activity+json") - |> json(UserView.render("followers.json", %{user: user})) + |> json(UserView.render("followers.json", %{user: user, for: for_user})) end end @@ -325,4 +339,17 @@ defp set_requester_reachable(%Plug.Conn{} = conn, _) do conn end + + defp ensure_user_keys_present_and_maybe_refresh_for_user(user, for_user) do + {:ok, new_user} = User.ensure_keys_present(user) + + for_user = + if new_user != user and match?(%User{}, for_user) do + User.get_cached_by_nickname(for_user.nickname) + else + for_user + end + + {new_user, for_user} + end end diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index d53fa8a35..e03a3a2e5 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -623,8 +623,6 @@ defmodule Pleroma.Web.Router do # XXX: not really ostatus pipe_through(:ostatus) - get("/users/:nickname/followers", ActivityPubController, :followers) - get("/users/:nickname/following", ActivityPubController, :following) get("/users/:nickname/outbox", ActivityPubController, :outbox) get("/objects/:uuid/likes", ActivityPubController, :object_likes) end @@ -656,6 +654,12 @@ defmodule Pleroma.Web.Router do pipe_through(:oauth_write) post("/users/:nickname/outbox", ActivityPubController, :update_outbox) end + + scope [] do + pipe_through(:oauth_read_or_public) + get("/users/:nickname/followers", ActivityPubController, :followers) + get("/users/:nickname/following", ActivityPubController, :following) + end end scope "/relay", Pleroma.Web.ActivityPub do diff --git a/test/web/activity_pub/activity_pub_controller_test.exs b/test/web/activity_pub/activity_pub_controller_test.exs index cbc25bcdb..452172bb4 100644 --- a/test/web/activity_pub/activity_pub_controller_test.exs +++ b/test/web/activity_pub/activity_pub_controller_test.exs @@ -12,6 +12,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do alias Pleroma.Web.ActivityPub.ObjectView alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.Utils + alias Pleroma.Web.CommonAPI setup_all do Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end) @@ -564,6 +565,34 @@ test "it returns returns a uri if the user has 'hide_followers' set", %{conn: co assert is_binary(result["first"]) end + test "it returns a 403 error on pages, if the user has 'hide_followers' set and the request is not authenticated", + %{conn: conn} do + user = insert(:user, %{info: %{hide_followers: true}}) + + result = + conn + |> get("/users/#{user.nickname}/followers?page=1") + + assert result.status == 403 + assert result.resp_body == "" + end + + test "it renders the page, if the user has 'hide_followers' set and the request is authenticated with the same user", + %{conn: conn} do + user = insert(:user, %{info: %{hide_followers: true}}) + other_user = insert(:user) + {:ok, _other_user, user, _activity} = CommonAPI.follow(other_user, user) + + result = + conn + |> assign(:user, user) + |> get("/users/#{user.nickname}/followers?page=1") + |> json_response(200) + + assert result["totalItems"] == 1 + assert result["orderedItems"] == [other_user.ap_id] + end + test "it works for more than 10 users", %{conn: conn} do user = insert(:user) @@ -618,6 +647,34 @@ test "it returns a uri if the user has 'hide_follows' set", %{conn: conn} do assert is_binary(result["first"]) end + test "it returns a 403 error on pages, if the user has 'hide_follows' set and the request is not authenticated", + %{conn: conn} do + user = insert(:user, %{info: %{hide_follows: true}}) + + result = + conn + |> get("/users/#{user.nickname}/following?page=1") + + assert result.status == 403 + assert result.resp_body == "" + end + + test "it renders the page, if the user has 'hide_follows' set and the request is authenticated with the same user", + %{conn: conn} do + user = insert(:user, %{info: %{hide_follows: true}}) + other_user = insert(:user) + {:ok, user, _other_user, _activity} = CommonAPI.follow(user, other_user) + + result = + conn + |> assign(:user, user) + |> get("/users/#{user.nickname}/following?page=1") + |> json_response(200) + + assert result["totalItems"] == 1 + assert result["orderedItems"] == [other_user.ap_id] + end + test "it works for more than 10 users", %{conn: conn} do user = insert(:user) From f40004e7463bafd07ba70046561dbf937bbf8646 Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 12 Jul 2019 21:49:16 +0300 Subject: [PATCH 07/25] Add changelog entries for follower/following collection behaviour changes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 942733ab6..405b7680c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **Breaking:** Configuration: A setting to explicitly disable the mailer was added, defaulting to true, if you are using a mailer add `config :pleroma, Pleroma.Emails.Mailer, enabled: true` to your config - Configuration: OpenGraph and TwitterCard providers enabled by default - Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text +- Federation: Return 403 errors when trying to request pages from a user's follower/following collections if they have `hide_followers`/`hide_follows` set - NodeInfo: Return `skipThreadContainment` in `metadata` for the `skip_thread_containment` option ### Fixed @@ -16,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Handling of search timeouts (`/api/v1/search` and `/api/v2/search`) - Mastodon API: Embedded relationships not being properly rendered in the Account entity of Status entity - Mastodon API: Add `account_id`, `type`, `offset`, and `limit` to search API (`/api/v1/search` and `/api/v2/search`) +- ActivityPub C2S: follower/following collection pages being inaccessible even when authentifucated if `hide_followers`/ `hide_follows` was set ### Added - MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`) From b001b8891a0ae9d8c7291f8148eb68a354cd319f Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 12 Jul 2019 23:52:26 +0300 Subject: [PATCH 08/25] Merge the default options with custom ones in ReverseProxy and Pleroma.HTTP --- lib/pleroma/http/connection.ex | 2 +- lib/pleroma/http/http.ex | 5 +---- lib/pleroma/reverse_proxy/reverse_proxy.ex | 5 +++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/lib/pleroma/http/connection.ex b/lib/pleroma/http/connection.ex index c216cdcb1..a1460d303 100644 --- a/lib/pleroma/http/connection.ex +++ b/lib/pleroma/http/connection.ex @@ -29,7 +29,7 @@ def new(opts \\ []) do # fetch Hackney options # - defp hackney_options(opts) do + def hackney_options(opts) do options = Keyword.get(opts, :adapter, []) adapter_options = Pleroma.Config.get([:http, :adapter], []) proxy_url = Pleroma.Config.get([:http, :proxy_url], nil) diff --git a/lib/pleroma/http/http.ex b/lib/pleroma/http/http.ex index c96ee7353..dec24458a 100644 --- a/lib/pleroma/http/http.ex +++ b/lib/pleroma/http/http.ex @@ -65,10 +65,7 @@ defp process_sni_options(options, url) do end def process_request_options(options) do - case Pleroma.Config.get([:http, :proxy_url]) do - nil -> options - proxy -> options ++ [proxy: proxy] - end + Keyword.merge(Pleroma.HTTP.Connection.hackney_options([]), options) end @doc """ diff --git a/lib/pleroma/reverse_proxy/reverse_proxy.ex b/lib/pleroma/reverse_proxy/reverse_proxy.ex index bf31e9cba..1f98f215c 100644 --- a/lib/pleroma/reverse_proxy/reverse_proxy.ex +++ b/lib/pleroma/reverse_proxy/reverse_proxy.ex @@ -61,7 +61,7 @@ defmodule Pleroma.ReverseProxy do * `http`: options for [hackney](https://github.com/benoitc/hackney). """ - @default_hackney_options [] + @default_hackney_options [pool: :media] @inline_content_types [ "image/gif", @@ -94,7 +94,8 @@ def call(_conn, _url, _opts \\ []) def call(conn = %{method: method}, url, opts) when method in @methods do hackney_opts = - @default_hackney_options + Pleroma.HTTP.Connection.hackney_options([]) + |> Keyword.merge(@default_hackney_options) |> Keyword.merge(Keyword.get(opts, :http, [])) |> HTTP.process_request_options() From fa7e0c4262f8844bb6224c200f7d41720607fcac Mon Sep 17 00:00:00 2001 From: rinpatch Date: Fri, 12 Jul 2019 23:53:21 +0300 Subject: [PATCH 09/25] Workaround for remote server certificate chain issues --- config/config.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/config.exs b/config/config.exs index 99b500993..eb663f3ec 100644 --- a/config/config.exs +++ b/config/config.exs @@ -194,6 +194,8 @@ send_user_agent: true, adapter: [ ssl_options: [ + # Workaround for remote server certificate chain issues + partial_chain: &:hackney_connect.partial_chain/1, # We don't support TLS v1.3 yet versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"] ] From 29ffe81c2e2235ed723516e74a50b025af688b9b Mon Sep 17 00:00:00 2001 From: rinpatch Date: Sat, 13 Jul 2019 02:04:26 +0300 Subject: [PATCH 10/25] Add a changelog entry for tolerating incorrect chain order --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 942733ab6..1ee845031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed - Not being able to pin unlisted posts - Metadata rendering errors resulting in the entire page being inaccessible +- Federation/MediaProxy not working with instances that have wrong certificate order - Mastodon API: Handling of search timeouts (`/api/v1/search` and `/api/v2/search`) - Mastodon API: Embedded relationships not being properly rendered in the Account entity of Status entity - Mastodon API: Add `account_id`, `type`, `offset`, and `limit` to search API (`/api/v1/search` and `/api/v2/search`) From 369e9bb42fc907f2e3f92e7e44dc52d6940dc046 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 13 Jul 2019 14:49:39 +0300 Subject: [PATCH 11/25] [#1041] Rate-limited status actions (per user and per user+status). --- CHANGELOG.md | 3 +- config/config.exs | 4 +- lib/pleroma/plugs/rate_limiter.ex | 67 ++++++++++++---- .../mastodon_api/mastodon_api_controller.ex | 22 ++++- test/plugs/rate_limiter_test.exs | 80 ++++++++++++++++--- 5 files changed, 149 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 405b7680c..c6c1ba1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`) -Configuration: `federation_incoming_replies_max_depth` option +- Configuration: `federation_incoming_replies_max_depth` option - Mastodon API: Support for the [`tagged` filter](https://github.com/tootsuite/mastodon/pull/9755) in [`GET /api/v1/accounts/:id/statuses`](https://docs.joinmastodon.org/api/rest/accounts/#get-api-v1-accounts-id-statuses) - Mastodon API, streaming: Add support for passing the token in the `Sec-WebSocket-Protocol` header - Mastodon API, extension: Ability to reset avatar, profile banner, and background @@ -32,6 +32,7 @@ Configuration: `federation_incoming_replies_max_depth` option - Added synchronization of following/followers counters for external users - Configuration: `enabled` option for `Pleroma.Emails.Mailer`, defaulting to `false`. - Mastodon API: Add support for categories for custom emojis by reusing the group feature. +- Configuration: Pleroma.Plugs.RateLimiter `bucket_name`, `params` options. ### Changed - Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text diff --git a/config/config.exs b/config/config.exs index 99b500993..3d48a5584 100644 --- a/config/config.exs +++ b/config/config.exs @@ -519,7 +519,9 @@ config :pleroma, :rate_limit, search: [{1000, 10}, {1000, 30}], - app_account_creation: {1_800_000, 25} + app_account_creation: {1_800_000, 25}, + statuses_actions: {10_000, 15}, + status_id_action: {60_000, 3} # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. diff --git a/lib/pleroma/plugs/rate_limiter.ex b/lib/pleroma/plugs/rate_limiter.ex index c5e0957e8..31388f574 100644 --- a/lib/pleroma/plugs/rate_limiter.ex +++ b/lib/pleroma/plugs/rate_limiter.ex @@ -31,12 +31,28 @@ defmodule Pleroma.Plugs.RateLimiter do ## Usage + AllowedSyntax: + + plug(Pleroma.Plugs.RateLimiter, :limiter_name) + plug(Pleroma.Plugs.RateLimiter, {:limiter_name, options}) + + Allowed options: + + * `bucket_name` overrides bucket name (e.g. to have a separate limit for a set of actions) + * `params` appends values of specified request params (e.g. ["id"]) to bucket name + Inside a controller: plug(Pleroma.Plugs.RateLimiter, :one when action == :one) plug(Pleroma.Plugs.RateLimiter, :two when action in [:two, :three]) - or inside a router pipiline: + plug( + Pleroma.Plugs.RateLimiter, + {:status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]} + when action in ~w(fav_status unfav_status)a + ) + + or inside a router pipeline: pipeline :api do ... @@ -49,33 +65,56 @@ defmodule Pleroma.Plugs.RateLimiter do alias Pleroma.User - def init(limiter_name) do + def init(limiter_name) when is_atom(limiter_name) do + init({limiter_name, []}) + end + + def init({limiter_name, opts}) do case Pleroma.Config.get([:rate_limit, limiter_name]) do nil -> nil - config -> {limiter_name, config} + config -> {limiter_name, config, opts} end end - # do not limit if there is no limiter configuration + # Do not limit if there is no limiter configuration def call(conn, nil), do: conn - def call(conn, opts) do - case check_rate(conn, opts) do - {:ok, _count} -> conn - {:error, _count} -> render_throttled_error(conn) + def call(conn, settings) do + case check_rate(conn, settings) do + {:ok, _count} -> + conn + + {:error, _count} -> + render_throttled_error(conn) end end - defp check_rate(%{assigns: %{user: %User{id: user_id}}}, {limiter_name, [_, {scale, limit}]}) do - ExRated.check_rate("#{limiter_name}:#{user_id}", scale, limit) + defp bucket_name(conn, limiter_name, opts) do + bucket_name = opts[:bucket_name] || limiter_name + + if params_names = opts[:params] do + params_values = for p <- Enum.sort(params_names), do: conn.params[p] + Enum.join([bucket_name] ++ params_values, ":") + else + bucket_name + end end - defp check_rate(conn, {limiter_name, [{scale, limit} | _]}) do - ExRated.check_rate("#{limiter_name}:#{ip(conn)}", scale, limit) + defp check_rate( + %{assigns: %{user: %User{id: user_id}}} = conn, + {limiter_name, [_, {scale, limit}], opts} + ) do + bucket_name = bucket_name(conn, limiter_name, opts) + ExRated.check_rate("#{bucket_name}:#{user_id}", scale, limit) end - defp check_rate(conn, {limiter_name, {scale, limit}}) do - check_rate(conn, {limiter_name, [{scale, limit}]}) + defp check_rate(conn, {limiter_name, [{scale, limit} | _], opts}) do + bucket_name = bucket_name(conn, limiter_name, opts) + ExRated.check_rate("#{bucket_name}:#{ip(conn)}", scale, limit) + end + + defp check_rate(conn, {limiter_name, {scale, limit}, opts}) do + check_rate(conn, {limiter_name, [{scale, limit}, {scale, limit}], opts}) end def ip(%{remote_ip: remote_ip}) do diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index 8c2033c3a..76648b9f7 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -15,6 +15,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do alias Pleroma.Notification alias Pleroma.Object alias Pleroma.Pagination + alias Pleroma.Plugs.RateLimiter alias Pleroma.Repo alias Pleroma.ScheduledActivity alias Pleroma.Stats @@ -46,8 +47,25 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do require Logger - plug(Pleroma.Plugs.RateLimiter, :app_account_creation when action == :account_register) - plug(Pleroma.Plugs.RateLimiter, :search when action in [:search, :search2, :account_search]) + @rate_limited_status_crud_actions ~w(post_status delete_status)a + @rate_limited_status_reactions ~w(reblog_status unreblog_status fav_status unfav_status)a + @rate_limited_status_actions @rate_limited_status_crud_actions ++ @rate_limited_status_reactions + + plug( + RateLimiter, + {:status_id_action, bucket_name: "status_id_action:reblog_unreblog", params: ["id"]} + when action in ~w(reblog_status unreblog_status)a + ) + + plug( + RateLimiter, + {:status_id_action, bucket_name: "status_id_action:fav_unfav", params: ["id"]} + when action in ~w(fav_status unfav_status)a + ) + + plug(RateLimiter, :statuses_actions when action in @rate_limited_status_actions) + plug(RateLimiter, :app_account_creation when action == :account_register) + plug(RateLimiter, :search when action in [:search, :search2, :account_search]) @local_mastodon_name "Mastodon-Local" diff --git a/test/plugs/rate_limiter_test.exs b/test/plugs/rate_limiter_test.exs index f8251b5c7..395095079 100644 --- a/test/plugs/rate_limiter_test.exs +++ b/test/plugs/rate_limiter_test.exs @@ -10,12 +10,13 @@ defmodule Pleroma.Plugs.RateLimiterTest do import Pleroma.Factory - @limiter_name :testing + # Note: each example must work with separate buckets in order to prevent concurrency issues test "init/1" do - Pleroma.Config.put([:rate_limit, @limiter_name], {1, 1}) + limiter_name = :test_init + Pleroma.Config.put([:rate_limit, limiter_name], {1, 1}) - assert {@limiter_name, {1, 1}} == RateLimiter.init(@limiter_name) + assert {limiter_name, {1, 1}, []} == RateLimiter.init(limiter_name) assert nil == RateLimiter.init(:foo) end @@ -24,14 +25,15 @@ test "ip/1" do end test "it restricts by opts" do + limiter_name = :test_opts scale = 1000 limit = 5 - Pleroma.Config.put([:rate_limit, @limiter_name], {scale, limit}) + Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit}) - opts = RateLimiter.init(@limiter_name) + opts = RateLimiter.init(limiter_name) conn = conn(:get, "/") - bucket_name = "#{@limiter_name}:#{RateLimiter.ip(conn)}" + bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}" conn = RateLimiter.call(conn, opts) assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit) @@ -65,18 +67,78 @@ test "it restricts by opts" do refute conn.halted end + test "`bucket_name` option overrides default bucket name" do + limiter_name = :test_bucket_name + scale = 1000 + limit = 5 + + Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit}) + base_bucket_name = "#{limiter_name}:group1" + opts = RateLimiter.init({limiter_name, bucket_name: base_bucket_name}) + + conn = conn(:get, "/") + default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}" + customized_bucket_name = "#{base_bucket_name}:#{RateLimiter.ip(conn)}" + + RateLimiter.call(conn, opts) + assert {1, 4, _, _, _} = ExRated.inspect_bucket(customized_bucket_name, scale, limit) + assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit) + end + + test "`params` option appends specified params' values to bucket name" do + limiter_name = :test_params + scale = 1000 + limit = 5 + + Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit}) + opts = RateLimiter.init({limiter_name, params: ["id"]}) + id = "1" + + conn = conn(:get, "/?id=#{id}") + conn = Plug.Conn.fetch_query_params(conn) + + default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}" + parametrized_bucket_name = "#{limiter_name}:#{id}:#{RateLimiter.ip(conn)}" + + RateLimiter.call(conn, opts) + assert {1, 4, _, _, _} = ExRated.inspect_bucket(parametrized_bucket_name, scale, limit) + assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit) + end + + test "it supports combination of options modifying bucket name" do + limiter_name = :test_options_combo + scale = 1000 + limit = 5 + + Pleroma.Config.put([:rate_limit, limiter_name], {scale, limit}) + base_bucket_name = "#{limiter_name}:group1" + opts = RateLimiter.init({limiter_name, bucket_name: base_bucket_name, params: ["id"]}) + id = "100" + + conn = conn(:get, "/?id=#{id}") + conn = Plug.Conn.fetch_query_params(conn) + + default_bucket_name = "#{limiter_name}:#{RateLimiter.ip(conn)}" + parametrized_bucket_name = "#{base_bucket_name}:#{id}:#{RateLimiter.ip(conn)}" + + RateLimiter.call(conn, opts) + assert {1, 4, _, _, _} = ExRated.inspect_bucket(parametrized_bucket_name, scale, limit) + assert {0, 5, _, _, _} = ExRated.inspect_bucket(default_bucket_name, scale, limit) + end + test "optional limits for authenticated users" do + limiter_name = :test_authenticated Ecto.Adapters.SQL.Sandbox.checkout(Pleroma.Repo) scale = 1000 limit = 5 - Pleroma.Config.put([:rate_limit, @limiter_name], [{1, 10}, {scale, limit}]) + Pleroma.Config.put([:rate_limit, limiter_name], [{1, 10}, {scale, limit}]) - opts = RateLimiter.init(@limiter_name) + opts = RateLimiter.init(limiter_name) user = insert(:user) conn = conn(:get, "/") |> assign(:user, user) - bucket_name = "#{@limiter_name}:#{user.id}" + bucket_name = "#{limiter_name}:#{user.id}" conn = RateLimiter.call(conn, opts) assert {1, 4, _, _, _} = ExRated.inspect_bucket(bucket_name, scale, limit) From b74d11e20ab214d533f746bee81fde589d319f64 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 13 Jul 2019 15:13:26 +0300 Subject: [PATCH 12/25] [#1041] Added documentation on existing rate limiters. --- docs/config.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/config.md b/docs/config.md index 140789d87..3ec13cff2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -640,3 +640,10 @@ A keyword list of rate limiters where a key is a limiter name and value is the l It is also possible to have different limits for unauthenticated and authenticated users: the keyword value must be a list of two tuples where the first one is a config for unauthenticated users and the second one is for authenticated. See [`Pleroma.Plugs.RateLimiter`](Pleroma.Plugs.RateLimiter.html) documentation for examples. + +Supported rate limiters: + +* `:search` for the search requests (account & status search etc.) +* `:app_account_creation` for registering user accounts from the same IP address +* `:statuses_actions` for create / delete / fav / unfav / reblog / unreblog actions on any statuses +* `:status_id_action` for fav / unfav or reblog / unreblog actions on the same status by the same user From d72876c57dd8f519f63f7bb14abcfaceedf41410 Mon Sep 17 00:00:00 2001 From: Ivan Tashkinov Date: Sat, 13 Jul 2019 15:21:50 +0300 Subject: [PATCH 13/25] [#1041] Minor refactoring. --- lib/pleroma/web/mastodon_api/mastodon_api_controller.ex | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index 76648b9f7..8a7b75025 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -47,9 +47,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do require Logger - @rate_limited_status_crud_actions ~w(post_status delete_status)a - @rate_limited_status_reactions ~w(reblog_status unreblog_status fav_status unfav_status)a - @rate_limited_status_actions @rate_limited_status_crud_actions ++ @rate_limited_status_reactions + @rate_limited_status_actions ~w(reblog_status unreblog_status fav_status unfav_status + post_status delete_status)a plug( RateLimiter, From 80c46d6d8b84d77d86efc32c1d2af225c1eada33 Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sat, 13 Jul 2019 18:30:45 +0000 Subject: [PATCH 14/25] nodeinfo: implement MRF transparency exclusions --- CHANGELOG.md | 1 + config/config.exs | 1 + docs/config.md | 1 + .../web/nodeinfo/nodeinfo_controller.ex | 6 ++- test/web/node_info_test.exs | 43 +++++++++++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index add8a0348..bebb438b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added - MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`) +- MRF: Support for excluding specific domains from Transparency. - Configuration: `federation_incoming_replies_max_depth` option - Mastodon API: Support for the [`tagged` filter](https://github.com/tootsuite/mastodon/pull/9755) in [`GET /api/v1/accounts/:id/statuses`](https://docs.joinmastodon.org/api/rest/accounts/#get-api-v1-accounts-id-statuses) - Mastodon API, streaming: Add support for passing the token in the `Sec-WebSocket-Protocol` header diff --git a/config/config.exs b/config/config.exs index 889238f0f..2ffa8c621 100644 --- a/config/config.exs +++ b/config/config.exs @@ -240,6 +240,7 @@ "text/bbcode" ], mrf_transparency: true, + mrf_transparency_exclusions: [], autofollowed_nicknames: [], max_pinned_statuses: 1, no_attachment_links: false, diff --git a/docs/config.md b/docs/config.md index 3ec13cff2..639c5689c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -106,6 +106,7 @@ config :pleroma, Pleroma.Emails.Mailer, * `managed_config`: Whenether the config for pleroma-fe is configured in this config or in ``static/config.json`` * `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML) * `mrf_transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). +* `mrf_transparency_exclusions`: Exclude specific instance names from MRF transparency. * `scope_copy`: Copy the scope (private/unlisted/public) in replies to posts by default. * `subject_line_behavior`: Allows changing the default behaviour of subject lines in replies. Valid values: * "email": Copy and preprend re:, as in email. diff --git a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex index cd9a4f4a8..a1d7fcc7d 100644 --- a/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex +++ b/lib/pleroma/web/nodeinfo/nodeinfo_controller.ex @@ -34,8 +34,11 @@ def schemas(conn, _params) do def raw_nodeinfo do stats = Stats.get_stats() + exclusions = Config.get([:instance, :mrf_transparency_exclusions]) + mrf_simple = Config.get(:mrf_simple) + |> Enum.map(fn {k, v} -> {k, Enum.reject(v, fn v -> v in exclusions end)} end) |> Enum.into(%{}) # This horror is needed to convert regex sigils to strings @@ -86,7 +89,8 @@ def raw_nodeinfo do mrf_simple: mrf_simple, mrf_keyword: mrf_keyword, mrf_user_allowlist: mrf_user_allowlist, - quarantined_instances: quarantined + quarantined_instances: quarantined, + exclusions: length(exclusions) > 0 } else %{} diff --git a/test/web/node_info_test.exs b/test/web/node_info_test.exs index be1173513..d7f848bfa 100644 --- a/test/web/node_info_test.exs +++ b/test/web/node_info_test.exs @@ -83,4 +83,47 @@ test "it returns the safe_dm_mentions feature if enabled", %{conn: conn} do Pleroma.Config.put([:instance, :safe_dm_mentions], option) end + + test "it shows MRF transparency data if enabled", %{conn: conn} do + option = Pleroma.Config.get([:instance, :mrf_transparency]) + Pleroma.Config.put([:instance, :mrf_transparency], true) + + simple_config = %{"reject" => ["example.com"]} + Pleroma.Config.put(:mrf_simple, simple_config) + + response = + conn + |> get("/nodeinfo/2.1.json") + |> json_response(:ok) + + assert response["metadata"]["federation"]["mrf_simple"] == simple_config + + Pleroma.Config.put([:instance, :mrf_transparency], option) + Pleroma.Config.put(:mrf_simple, %{}) + end + + test "it performs exclusions from MRF transparency data if configured", %{conn: conn} do + option = Pleroma.Config.get([:instance, :mrf_transparency]) + Pleroma.Config.put([:instance, :mrf_transparency], true) + + exclusions = Pleroma.Config.get([:instance, :mrf_transparency_exclusions]) + Pleroma.Config.put([:instance, :mrf_transparency_exclusions], ["other.site"]) + + simple_config = %{"reject" => ["example.com", "other.site"]} + expected_config = %{"reject" => ["example.com"]} + + Pleroma.Config.put(:mrf_simple, simple_config) + + response = + conn + |> get("/nodeinfo/2.1.json") + |> json_response(:ok) + + assert response["metadata"]["federation"]["mrf_simple"] == expected_config + assert response["metadata"]["federation"]["exclusions"] == true + + Pleroma.Config.put([:instance, :mrf_transparency], option) + Pleroma.Config.put([:instance, :mrf_transparency_exclusions], exclusions) + Pleroma.Config.put(:mrf_simple, %{}) + end end From 0cc638b96874312bce29e477a0ce6e46a92142bf Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sat, 13 Jul 2019 19:00:03 +0000 Subject: [PATCH 15/25] docs: note that exclusions usage will be included in the transparency metrics if used --- docs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 639c5689c..a65b7a560 100644 --- a/docs/config.md +++ b/docs/config.md @@ -106,7 +106,7 @@ config :pleroma, Pleroma.Emails.Mailer, * `managed_config`: Whenether the config for pleroma-fe is configured in this config or in ``static/config.json`` * `allowed_post_formats`: MIME-type list of formats allowed to be posted (transformed into HTML) * `mrf_transparency`: Make the content of your Message Rewrite Facility settings public (via nodeinfo). -* `mrf_transparency_exclusions`: Exclude specific instance names from MRF transparency. +* `mrf_transparency_exclusions`: Exclude specific instance names from MRF transparency. The use of the exclusions feature will be disclosed in nodeinfo as a boolean value. * `scope_copy`: Copy the scope (private/unlisted/public) in replies to posts by default. * `subject_line_behavior`: Allows changing the default behaviour of subject lines in replies. Valid values: * "email": Copy and preprend re:, as in email. From f4447d82b814e4710a0d7499bc0707773ac1e440 Mon Sep 17 00:00:00 2001 From: Alex S Date: Thu, 11 Jul 2019 16:04:42 +0300 Subject: [PATCH 16/25] parsers configurable --- config/config.exs | 7 ++++++- lib/pleroma/web/rich_media/parser.ex | 12 +++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/config/config.exs b/config/config.exs index 2ffa8c621..7d539f994 100644 --- a/config/config.exs +++ b/config/config.exs @@ -339,7 +339,12 @@ config :pleroma, :rich_media, enabled: true, ignore_hosts: [], - ignore_tld: ["local", "localdomain", "lan"] + ignore_tld: ["local", "localdomain", "lan"], + parsers: [ + Pleroma.Web.RichMedia.Parsers.TwitterCard, + Pleroma.Web.RichMedia.Parsers.OGP, + Pleroma.Web.RichMedia.Parsers.OEmbed + ] config :pleroma, :media_proxy, enabled: false, diff --git a/lib/pleroma/web/rich_media/parser.ex b/lib/pleroma/web/rich_media/parser.ex index 21cd47890..0d2523338 100644 --- a/lib/pleroma/web/rich_media/parser.ex +++ b/lib/pleroma/web/rich_media/parser.ex @@ -3,12 +3,6 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Web.RichMedia.Parser do - @parsers [ - Pleroma.Web.RichMedia.Parsers.OGP, - Pleroma.Web.RichMedia.Parsers.TwitterCard, - Pleroma.Web.RichMedia.Parsers.OEmbed - ] - @hackney_options [ pool: :media, recv_timeout: 2_000, @@ -16,6 +10,10 @@ defmodule Pleroma.Web.RichMedia.Parser do with_body: true ] + defp parsers do + Pleroma.Config.get([:rich_media, :parsers]) + end + def parse(nil), do: {:error, "No URL provided"} if Pleroma.Config.get(:env) == :test do @@ -48,7 +46,7 @@ defp parse_url(url) do end defp maybe_parse(html) do - Enum.reduce_while(@parsers, %{}, fn parser, acc -> + Enum.reduce_while(parsers(), %{}, fn parser, acc -> case parser.parse(html, acc) do {:ok, data} -> {:halt, data} {:error, _msg} -> {:cont, acc} From 7af27c143d6c6f288be1e7d2fd2e2e9a439ececf Mon Sep 17 00:00:00 2001 From: Alex S Date: Sun, 14 Jul 2019 09:20:54 +0300 Subject: [PATCH 17/25] changelog & docs --- CHANGELOG.md | 1 + docs/config.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bebb438b1..694097878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed - Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text - Admin API: changed json structure for saving config settings. +- RichMedia: parsers and their order are configured in `rich_media` config. ## [1.0.0] - 2019-06-29 ### Security diff --git a/docs/config.md b/docs/config.md index a65b7a560..9a64f0ed7 100644 --- a/docs/config.md +++ b/docs/config.md @@ -425,6 +425,7 @@ This config contains two queues: `federator_incoming` and `federator_outgoing`. * `enabled`: if enabled the instance will parse metadata from attached links to generate link previews * `ignore_hosts`: list of hosts which will be ignored by the metadata parser. For example `["accounts.google.com", "xss.website"]`, defaults to `[]`. * `ignore_tld`: list TLDs (top-level domains) which will ignore for parse metadata. default is ["local", "localdomain", "lan"] +* `parsers`: list of Rich Media parsers ## :fetch_initial_posts * `enabled`: if enabled, when a new user is federated with, fetch some of their latest posts From efa9a13d4e27c797ae02a44ddc95fd81fea36804 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 14 Jul 2019 13:31:43 +0200 Subject: [PATCH 18/25] HttpRequestMock: Add missing mocks for object containment tests --- test/support/http_request_mock.ex | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index ff6bb78f9..80586426b 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -879,6 +879,30 @@ def get( }} end + def get("https://info.pleroma.site/activity.json", _, _, Accept: "application/activity+json") do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/https__info.pleroma.site_activity.json") + }} + end + + def get("https://info.pleroma.site/activity2.json", _, _, Accept: "application/activity+json") do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/https__info.pleroma.site_activity2.json") + }} + end + + def get("https://info.pleroma.site/activity3.json", _, _, Accept: "application/activity+json") do + {:ok, + %Tesla.Env{ + status: 200, + body: File.read!("test/fixtures/tesla_mock/https__info.pleroma.site_activity3.json") + }} + end + def get(url, query, body, headers) do {:error, "Not implemented the mock response for get #{inspect(url)}, #{query}, #{inspect(body)}, #{ From f00562ed6bef980bc14038b15078d3427876f7db Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 14 Jul 2019 13:39:05 +0200 Subject: [PATCH 19/25] HttpRequestMock: Add 404s on OStatus fetching for info.pleroma.site --- test/support/http_request_mock.ex | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/support/http_request_mock.ex b/test/support/http_request_mock.ex index 80586426b..7811f7807 100644 --- a/test/support/http_request_mock.ex +++ b/test/support/http_request_mock.ex @@ -887,6 +887,10 @@ def get("https://info.pleroma.site/activity.json", _, _, Accept: "application/ac }} end + def get("https://info.pleroma.site/activity.json", _, _, _) do + {:ok, %Tesla.Env{status: 404, body: ""}} + end + def get("https://info.pleroma.site/activity2.json", _, _, Accept: "application/activity+json") do {:ok, %Tesla.Env{ @@ -895,6 +899,10 @@ def get("https://info.pleroma.site/activity2.json", _, _, Accept: "application/a }} end + def get("https://info.pleroma.site/activity2.json", _, _, _) do + {:ok, %Tesla.Env{status: 404, body: ""}} + end + def get("https://info.pleroma.site/activity3.json", _, _, Accept: "application/activity+json") do {:ok, %Tesla.Env{ @@ -903,6 +911,10 @@ def get("https://info.pleroma.site/activity3.json", _, _, Accept: "application/a }} end + def get("https://info.pleroma.site/activity3.json", _, _, _) do + {:ok, %Tesla.Env{status: 404, body: ""}} + end + def get(url, query, body, headers) do {:error, "Not implemented the mock response for get #{inspect(url)}, #{query}, #{inspect(body)}, #{ From 40d0a198e20d577b51339f4026d672b1aa968be1 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 14 Jul 2019 12:02:16 +0200 Subject: [PATCH 20/25] Object.Fetcher: Handle error on Containment.contain_origin/2 --- 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 101c21f96..82250ab8d 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -38,6 +38,7 @@ def fetch_object_from_id(id, options \\ []) do "type" => "Create", "to" => data["to"], "cc" => data["cc"], + # TODO: Should we seriously keep this attributedTo thing? "actor" => data["actor"] || data["attributedTo"], "object" => data }, @@ -56,6 +57,9 @@ def fetch_object_from_id(id, options \\ []) do object = %Object{} -> {:ok, object} + :error -> + {:error, "Object containment failed."} + _e -> Logger.info("Couldn't get object via AP, trying out OStatus fetching...") From e7c39b7ac8f0462ab563d3cf51f24c76feab0e8d Mon Sep 17 00:00:00 2001 From: Alexander Strizhakov Date: Sun, 14 Jul 2019 13:29:31 +0000 Subject: [PATCH 21/25] Feature/1072 muting notifications --- CHANGELOG.md | 3 +- lib/pleroma/notification.ex | 70 +++++----- lib/pleroma/user.ex | 21 ++- lib/pleroma/user/info.ex | 28 ++++ lib/pleroma/web/mastodon_api/mastodon_api.ex | 5 +- .../mastodon_api/mastodon_api_controller.ex | 9 +- .../web/mastodon_api/views/account_view.ex | 2 +- .../web/twitter_api/twitter_api_controller.ex | 7 + ...2024_copy_muted_to_muted_notifications.exs | 24 ++++ test/notification_test.exs | 117 ++++++++++++++++- test/user_test.exs | 16 +++ .../mastodon_api_controller_test.exs | 120 ++++++++++++++++-- .../twitter_api_controller_test.exs | 32 +++++ 13 files changed, 390 insertions(+), 64 deletions(-) create mode 100644 priv/repo/migrations/20190711042024_copy_muted_to_muted_notifications.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 694097878..0cec3bf5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,13 +27,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Mastodon API: Support for the [`tagged` filter](https://github.com/tootsuite/mastodon/pull/9755) in [`GET /api/v1/accounts/:id/statuses`](https://docs.joinmastodon.org/api/rest/accounts/#get-api-v1-accounts-id-statuses) - Mastodon API, streaming: Add support for passing the token in the `Sec-WebSocket-Protocol` header - Mastodon API, extension: Ability to reset avatar, profile banner, and background +- Mastodon API: Add support for categories for custom emojis by reusing the group feature. +- Mastodon API: Add support for muting/unmuting notifications - Admin API: Return users' tags when querying reports - Admin API: Return avatar and display name when querying users - Admin API: Allow querying user by ID - Admin API: Added support for `tuples`. - Added synchronization of following/followers counters for external users - Configuration: `enabled` option for `Pleroma.Emails.Mailer`, defaulting to `false`. -- Mastodon API: Add support for categories for custom emojis by reusing the group feature. - Configuration: Pleroma.Plugs.RateLimiter `bucket_name`, `params` options. ### Changed diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex index a414afbbf..ee7b37aab 100644 --- a/lib/pleroma/notification.ex +++ b/lib/pleroma/notification.ex @@ -11,7 +11,6 @@ defmodule Pleroma.Notification do alias Pleroma.Pagination alias Pleroma.Repo alias Pleroma.User - alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.Push alias Pleroma.Web.Streamer @@ -32,31 +31,47 @@ def changeset(%Notification{} = notification, attrs) do |> cast(attrs, [:seen]) end - def for_user_query(user) do - Notification - |> where(user_id: ^user.id) - |> where( - [n, a], - fragment( - "? not in (SELECT ap_id FROM users WHERE info->'deactivated' @> 'true')", - a.actor - ) - ) - |> join(:inner, [n], activity in assoc(n, :activity)) - |> join(:left, [n, a], object in Object, - on: + def for_user_query(user, opts) do + query = + Notification + |> where(user_id: ^user.id) + |> where( + [n, a], fragment( - "(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)", - object.data, - a.data + "? not in (SELECT ap_id FROM users WHERE info->'deactivated' @> 'true')", + a.actor ) - ) - |> preload([n, a, o], activity: {a, object: o}) + ) + |> join(:inner, [n], activity in assoc(n, :activity)) + |> join(:left, [n, a], object in Object, + on: + fragment( + "(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)", + object.data, + a.data + ) + ) + |> preload([n, a, o], activity: {a, object: o}) + + if opts[:with_muted] do + query + else + where(query, [n, a], a.actor not in ^user.info.muted_notifications) + |> where([n, a], a.actor not in ^user.info.blocks) + |> where( + [n, a], + fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.info.domain_blocks + ) + |> join(:left, [n, a], tm in Pleroma.ThreadMute, + on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data) + ) + |> where([n, a, o, tm], is_nil(tm.id)) + end end def for_user(user, opts \\ %{}) do user - |> for_user_query() + |> for_user_query(opts) |> Pagination.fetch_paginated(opts) end @@ -179,11 +194,10 @@ def get_notified_from_activity( def get_notified_from_activity(_, _local_only), do: [] + @spec skip?(Activity.t(), User.t()) :: boolean() def skip?(activity, user) do [ :self, - :blocked, - :muted, :followers, :follows, :non_followers, @@ -193,21 +207,11 @@ def skip?(activity, user) do |> Enum.any?(&skip?(&1, activity, user)) end + @spec skip?(atom(), Activity.t(), User.t()) :: boolean() def skip?(:self, activity, user) do activity.data["actor"] == user.ap_id end - def skip?(:blocked, activity, user) do - actor = activity.data["actor"] - User.blocks?(user, %{ap_id: actor}) - end - - def skip?(:muted, activity, user) do - actor = activity.data["actor"] - - User.mutes?(user, %{ap_id: actor}) or CommonAPI.thread_muted?(user, activity) - end - def skip?( :followers, activity, diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index e5a6c2529..29c87d4a9 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -749,10 +749,13 @@ def get_recipients_from_activity(%Activity{recipients: to}) do |> Repo.all() end - def mute(muter, %User{ap_id: ap_id}) do + @spec mute(User.t(), User.t(), boolean()) :: {:ok, User.t()} | {:error, String.t()} + def mute(muter, %User{ap_id: ap_id}, notifications? \\ true) do + info = muter.info + info_cng = - muter.info - |> User.Info.add_to_mutes(ap_id) + User.Info.add_to_mutes(info, ap_id) + |> User.Info.add_to_muted_notifications(info, ap_id, notifications?) cng = change(muter) @@ -762,9 +765,11 @@ def mute(muter, %User{ap_id: ap_id}) do end def unmute(muter, %{ap_id: ap_id}) do + info = muter.info + info_cng = - muter.info - |> User.Info.remove_from_mutes(ap_id) + User.Info.remove_from_mutes(info, ap_id) + |> User.Info.remove_from_muted_notifications(info, ap_id) cng = change(muter) @@ -860,6 +865,12 @@ def unblock(blocker, %{ap_id: ap_id}) do def mutes?(nil, _), do: false def mutes?(user, %{ap_id: ap_id}), do: Enum.member?(user.info.mutes, ap_id) + @spec muted_notifications?(User.t() | nil, User.t() | map()) :: boolean() + def muted_notifications?(nil, _), do: false + + def muted_notifications?(user, %{ap_id: ap_id}), + do: Enum.member?(user.info.muted_notifications, ap_id) + def blocks?(%User{info: info} = _user, %{ap_id: ap_id}) do blocks = info.blocks domain_blocks = info.domain_blocks diff --git a/lib/pleroma/user/info.ex b/lib/pleroma/user/info.ex index 08e43ff0f..9beb3ddbd 100644 --- a/lib/pleroma/user/info.ex +++ b/lib/pleroma/user/info.ex @@ -24,6 +24,7 @@ defmodule Pleroma.User.Info do field(:domain_blocks, {:array, :string}, default: []) field(:mutes, {:array, :string}, default: []) field(:muted_reblogs, {:array, :string}, default: []) + field(:muted_notifications, {:array, :string}, default: []) field(:subscribers, {:array, :string}, default: []) field(:deactivated, :boolean, default: false) field(:no_rich_text, :boolean, default: false) @@ -120,6 +121,16 @@ def set_mutes(info, mutes) do |> validate_required([:mutes]) end + @spec set_notification_mutes(Changeset.t(), [String.t()], boolean()) :: Changeset.t() + def set_notification_mutes(changeset, muted_notifications, notifications?) do + if notifications? do + put_change(changeset, :muted_notifications, muted_notifications) + |> validate_required([:muted_notifications]) + else + changeset + end + end + def set_blocks(info, blocks) do params = %{blocks: blocks} @@ -136,14 +147,31 @@ def set_subscribers(info, subscribers) do |> validate_required([:subscribers]) end + @spec add_to_mutes(Info.t(), String.t()) :: Changeset.t() def add_to_mutes(info, muted) do set_mutes(info, Enum.uniq([muted | info.mutes])) end + @spec add_to_muted_notifications(Changeset.t(), Info.t(), String.t(), boolean()) :: + Changeset.t() + def add_to_muted_notifications(changeset, info, muted, notifications?) do + set_notification_mutes( + changeset, + Enum.uniq([muted | info.muted_notifications]), + notifications? + ) + end + + @spec remove_from_mutes(Info.t(), String.t()) :: Changeset.t() def remove_from_mutes(info, muted) do set_mutes(info, List.delete(info.mutes, muted)) end + @spec remove_from_muted_notifications(Changeset.t(), Info.t(), String.t()) :: Changeset.t() + def remove_from_muted_notifications(changeset, info, muted) do + set_notification_mutes(changeset, List.delete(info.muted_notifications, muted), true) + end + def add_to_block(info, blocked) do set_blocks(info, Enum.uniq([blocked | info.blocks])) end diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex index c82b20123..46944dcbc 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex @@ -53,7 +53,7 @@ def get_notifications(user, params \\ %{}) do options = cast_params(params) user - |> Notification.for_user_query() + |> Notification.for_user_query(options) |> restrict(:exclude_types, options) |> Pagination.fetch_paginated(params) end @@ -67,7 +67,8 @@ def get_scheduled_activities(user, params \\ %{}) do defp cast_params(params) do param_types = %{ exclude_types: {:array, :string}, - reblogs: :boolean + reblogs: :boolean, + with_muted: :boolean } changeset = cast({%{}, param_types}, params, Map.keys(param_types)) diff --git a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex index 8a7b75025..b3513b5bf 100644 --- a/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex +++ b/lib/pleroma/web/mastodon_api/mastodon_api_controller.ex @@ -1068,9 +1068,14 @@ def unfollow(%{assigns: %{user: follower}} = conn, %{"id" => id}) do end end - def mute(%{assigns: %{user: muter}} = conn, %{"id" => id}) do + def mute(%{assigns: %{user: muter}} = conn, %{"id" => id} = params) do + notifications = + if Map.has_key?(params, "notifications"), + do: params["notifications"] in [true, "True", "true", "1"], + else: true + with %User{} = muted <- User.get_cached_by_id(id), - {:ok, muter} <- User.mute(muter, muted) do + {:ok, muter} <- User.mute(muter, muted, notifications) do conn |> put_view(AccountView) |> render("relationship.json", %{user: muter, target: muted}) diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index 62c516f8e..65bab4062 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -52,7 +52,7 @@ def render("relationship.json", %{user: %User{} = user, target: %User{} = target followed_by: User.following?(target, user), blocking: User.blocks?(user, target), muting: User.mutes?(user, target), - muting_notifications: false, + muting_notifications: User.muted_notifications?(user, target), subscribing: User.subscribed_to?(user, target), requested: requested, domain_blocking: false, diff --git a/lib/pleroma/web/twitter_api/twitter_api_controller.ex b/lib/pleroma/web/twitter_api/twitter_api_controller.ex index 45ef7be3d..0313560a8 100644 --- a/lib/pleroma/web/twitter_api/twitter_api_controller.ex +++ b/lib/pleroma/web/twitter_api/twitter_api_controller.ex @@ -192,6 +192,13 @@ def dm_timeline(%{assigns: %{user: user}} = conn, params) do end def notifications(%{assigns: %{user: user}} = conn, params) do + params = + if Map.has_key?(params, "with_muted") do + Map.put(params, :with_muted, params["with_muted"] in [true, "True", "true", "1"]) + else + params + end + notifications = Notification.for_user(user, params) conn diff --git a/priv/repo/migrations/20190711042024_copy_muted_to_muted_notifications.exs b/priv/repo/migrations/20190711042024_copy_muted_to_muted_notifications.exs new file mode 100644 index 000000000..50669902e --- /dev/null +++ b/priv/repo/migrations/20190711042024_copy_muted_to_muted_notifications.exs @@ -0,0 +1,24 @@ +defmodule Pleroma.Repo.Migrations.CopyMutedToMutedNotifications do + use Ecto.Migration + alias Pleroma.User + + def change do + query = + User.Query.build(%{ + local: true, + active: true, + order_by: :id + }) + + Pleroma.Repo.stream(query) + |> Enum.each(fn + %{info: %{mutes: mutes} = info} = user -> + info_cng = + Ecto.Changeset.cast(info, %{muted_notifications: mutes}, [:muted_notifications]) + + Ecto.Changeset.change(user) + |> Ecto.Changeset.put_embed(:info, info_cng) + |> Pleroma.Repo.update() + end) + end +end diff --git a/test/notification_test.exs b/test/notification_test.exs index 1d36f14bf..dda570b49 100644 --- a/test/notification_test.exs +++ b/test/notification_test.exs @@ -74,26 +74,37 @@ test "it creates a notification for user and send to the 'user' and the 'user:no Task.await(task_user_notification) end - test "it doesn't create a notification for user if the user blocks the activity author" do + test "it creates a notification for user if the user blocks the activity author" do activity = insert(:note_activity) author = User.get_cached_by_ap_id(activity.data["actor"]) user = insert(:user) {:ok, user} = User.block(user, author) - refute Notification.create_notification(activity, user) + assert Notification.create_notification(activity, user) end - test "it doesn't create a notificatin for the user if the user mutes the activity author" do + test "it creates a notificatin for the user if the user mutes the activity author" do muter = insert(:user) muted = insert(:user) {:ok, _} = User.mute(muter, muted) muter = Repo.get(User, muter.id) {:ok, activity} = CommonAPI.post(muted, %{"status" => "Hi @#{muter.nickname}"}) - refute Notification.create_notification(activity, muter) + assert Notification.create_notification(activity, muter) end - test "it doesn't create a notification for an activity from a muted thread" do + test "notification created if user is muted without notifications" do + muter = insert(:user) + muted = insert(:user) + + {:ok, muter} = User.mute(muter, muted, false) + + {:ok, activity} = CommonAPI.post(muted, %{"status" => "Hi @#{muter.nickname}"}) + + assert Notification.create_notification(activity, muter) + end + + test "it creates a notification for an activity from a muted thread" do muter = insert(:user) other_user = insert(:user) {:ok, activity} = CommonAPI.post(muter, %{"status" => "hey"}) @@ -105,7 +116,7 @@ test "it doesn't create a notification for an activity from a muted thread" do "in_reply_to_status_id" => activity.id }) - refute Notification.create_notification(activity, muter) + assert Notification.create_notification(activity, muter) end test "it disables notifications from followers" do @@ -532,4 +543,98 @@ test "replying to a deleted post without tagging does not generate a notificatio assert Enum.empty?(Notification.for_user(user)) end end + + describe "for_user" do + test "it returns notifications for muted user without notifications" do + user = insert(:user) + muted = insert(:user) + {:ok, user} = User.mute(user, muted, false) + + {:ok, _activity} = TwitterAPI.create_status(muted, %{"status" => "hey @#{user.nickname}"}) + + assert length(Notification.for_user(user)) == 1 + end + + test "it doesn't return notifications for muted user with notifications" do + user = insert(:user) + muted = insert(:user) + {:ok, user} = User.mute(user, muted) + + {:ok, _activity} = TwitterAPI.create_status(muted, %{"status" => "hey @#{user.nickname}"}) + + assert Notification.for_user(user) == [] + end + + test "it doesn't return notifications for blocked user" do + user = insert(:user) + blocked = insert(:user) + {:ok, user} = User.block(user, blocked) + + {:ok, _activity} = TwitterAPI.create_status(blocked, %{"status" => "hey @#{user.nickname}"}) + + assert Notification.for_user(user) == [] + end + + test "it doesn't return notificatitons for blocked domain" do + user = insert(:user) + blocked = insert(:user, ap_id: "http://some-domain.com") + {:ok, user} = User.block_domain(user, "some-domain.com") + + {:ok, _activity} = TwitterAPI.create_status(blocked, %{"status" => "hey @#{user.nickname}"}) + + assert Notification.for_user(user) == [] + end + + test "it doesn't return notifications for muted thread" do + user = insert(:user) + another_user = insert(:user) + + {:ok, activity} = + TwitterAPI.create_status(another_user, %{"status" => "hey @#{user.nickname}"}) + + {:ok, _} = Pleroma.ThreadMute.add_mute(user.id, activity.data["context"]) + assert Notification.for_user(user) == [] + end + + test "it returns notifications for muted user with notifications and with_muted parameter" do + user = insert(:user) + muted = insert(:user) + {:ok, user} = User.mute(user, muted) + + {:ok, _activity} = TwitterAPI.create_status(muted, %{"status" => "hey @#{user.nickname}"}) + + assert length(Notification.for_user(user, %{with_muted: true})) == 1 + end + + test "it returns notifications for blocked user and with_muted parameter" do + user = insert(:user) + blocked = insert(:user) + {:ok, user} = User.block(user, blocked) + + {:ok, _activity} = TwitterAPI.create_status(blocked, %{"status" => "hey @#{user.nickname}"}) + + assert length(Notification.for_user(user, %{with_muted: true})) == 1 + end + + test "it returns notificatitons for blocked domain and with_muted parameter" do + user = insert(:user) + blocked = insert(:user, ap_id: "http://some-domain.com") + {:ok, user} = User.block_domain(user, "some-domain.com") + + {:ok, _activity} = TwitterAPI.create_status(blocked, %{"status" => "hey @#{user.nickname}"}) + + assert length(Notification.for_user(user, %{with_muted: true})) == 1 + end + + test "it returns notifications for muted thread with_muted parameter" do + user = insert(:user) + another_user = insert(:user) + + {:ok, activity} = + TwitterAPI.create_status(another_user, %{"status" => "hey @#{user.nickname}"}) + + {:ok, _} = Pleroma.ThreadMute.add_mute(user.id, activity.data["context"]) + assert length(Notification.for_user(user, %{with_muted: true})) == 1 + end + end end diff --git a/test/user_test.exs b/test/user_test.exs index 7c3fe976d..264b7a40e 100644 --- a/test/user_test.exs +++ b/test/user_test.exs @@ -687,10 +687,12 @@ test "it mutes people" do muted_user = insert(:user) refute User.mutes?(user, muted_user) + refute User.muted_notifications?(user, muted_user) {:ok, user} = User.mute(user, muted_user) assert User.mutes?(user, muted_user) + assert User.muted_notifications?(user, muted_user) end test "it unmutes users" do @@ -701,6 +703,20 @@ test "it unmutes users" do {:ok, user} = User.unmute(user, muted_user) refute User.mutes?(user, muted_user) + refute User.muted_notifications?(user, muted_user) + end + + test "it mutes user without notifications" do + user = insert(:user) + muted_user = insert(:user) + + refute User.mutes?(user, muted_user) + refute User.muted_notifications?(user, muted_user) + + {:ok, user} = User.mute(user, muted_user, false) + + assert User.mutes?(user, muted_user) + refute User.muted_notifications?(user, muted_user) end end diff --git a/test/web/mastodon_api/mastodon_api_controller_test.exs b/test/web/mastodon_api/mastodon_api_controller_test.exs index b7c050dbf..6ffa64dc8 100644 --- a/test/web/mastodon_api/mastodon_api_controller_test.exs +++ b/test/web/mastodon_api/mastodon_api_controller_test.exs @@ -1274,6 +1274,71 @@ test "destroy multiple", %{conn: conn} do result = json_response(conn_res, 200) assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result end + + test "doesn't see notifications after muting user with notifications", %{conn: conn} do + user = insert(:user) + user2 = insert(:user) + + {:ok, _, _, _} = CommonAPI.follow(user, user2) + {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) + + conn = assign(conn, :user, user) + + conn = get(conn, "/api/v1/notifications") + + assert length(json_response(conn, 200)) == 1 + + {:ok, user} = User.mute(user, user2) + + conn = assign(build_conn(), :user, user) + conn = get(conn, "/api/v1/notifications") + + assert json_response(conn, 200) == [] + end + + test "see notifications after muting user without notifications", %{conn: conn} do + user = insert(:user) + user2 = insert(:user) + + {:ok, _, _, _} = CommonAPI.follow(user, user2) + {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) + + conn = assign(conn, :user, user) + + conn = get(conn, "/api/v1/notifications") + + assert length(json_response(conn, 200)) == 1 + + {:ok, user} = User.mute(user, user2, false) + + conn = assign(build_conn(), :user, user) + conn = get(conn, "/api/v1/notifications") + + assert length(json_response(conn, 200)) == 1 + end + + test "see notifications after muting user with notifications and with_muted parameter", %{ + conn: conn + } do + user = insert(:user) + user2 = insert(:user) + + {:ok, _, _, _} = CommonAPI.follow(user, user2) + {:ok, _} = CommonAPI.post(user2, %{"status" => "hey @#{user.nickname}"}) + + conn = assign(conn, :user, user) + + conn = get(conn, "/api/v1/notifications") + + assert length(json_response(conn, 200)) == 1 + + {:ok, user} = User.mute(user, user2) + + conn = assign(build_conn(), :user, user) + conn = get(conn, "/api/v1/notifications", %{"with_muted" => "true"}) + + assert length(json_response(conn, 200)) == 1 + end end describe "reblogging" do @@ -2105,25 +2170,52 @@ test "following / unfollowing errors" do assert %{"error" => "Record not found"} = json_response(conn_res, 404) end - test "muting / unmuting a user", %{conn: conn} do - user = insert(:user) - other_user = insert(:user) + describe "mute/unmute" do + test "with notifications", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) - conn = - conn - |> assign(:user, user) - |> post("/api/v1/accounts/#{other_user.id}/mute") + conn = + conn + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/mute") - assert %{"id" => _id, "muting" => true} = json_response(conn, 200) + response = json_response(conn, 200) - user = User.get_cached_by_id(user.id) + assert %{"id" => _id, "muting" => true, "muting_notifications" => true} = response + user = User.get_cached_by_id(user.id) - conn = - build_conn() - |> assign(:user, user) - |> post("/api/v1/accounts/#{other_user.id}/unmute") + conn = + build_conn() + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/unmute") - assert %{"id" => _id, "muting" => false} = json_response(conn, 200) + response = json_response(conn, 200) + assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response + end + + test "without notifications", %{conn: conn} do + user = insert(:user) + other_user = insert(:user) + + conn = + conn + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/mute", %{"notifications" => "false"}) + + response = json_response(conn, 200) + + assert %{"id" => _id, "muting" => true, "muting_notifications" => false} = response + user = User.get_cached_by_id(user.id) + + conn = + build_conn() + |> assign(:user, user) + |> post("/api/v1/accounts/#{other_user.id}/unmute") + + response = json_response(conn, 200) + assert %{"id" => _id, "muting" => false, "muting_notifications" => false} = response + end end test "subscribing / unsubscribing to a user", %{conn: conn} do diff --git a/test/web/twitter_api/twitter_api_controller_test.exs b/test/web/twitter_api/twitter_api_controller_test.exs index 7ec0e101d..de6177575 100644 --- a/test/web/twitter_api/twitter_api_controller_test.exs +++ b/test/web/twitter_api/twitter_api_controller_test.exs @@ -521,6 +521,38 @@ test "with credentials", %{conn: conn, user: current_user} do for: current_user }) end + + test "muted user", %{conn: conn, user: current_user} do + other_user = insert(:user) + + {:ok, current_user} = User.mute(current_user, other_user) + + {:ok, _activity} = + ActivityBuilder.insert(%{"to" => [current_user.ap_id]}, %{user: other_user}) + + conn = + conn + |> with_credentials(current_user.nickname, "test") + |> get("/api/qvitter/statuses/notifications.json") + + assert json_response(conn, 200) == [] + end + + test "muted user with with_muted parameter", %{conn: conn, user: current_user} do + other_user = insert(:user) + + {:ok, current_user} = User.mute(current_user, other_user) + + {:ok, _activity} = + ActivityBuilder.insert(%{"to" => [current_user.ap_id]}, %{user: other_user}) + + conn = + conn + |> with_credentials(current_user.nickname, "test") + |> get("/api/qvitter/statuses/notifications.json", %{"with_muted" => "true"}) + + assert length(json_response(conn, 200)) == 1 + end end describe "POST /api/qvitter/statuses/notifications/read" do From e1c08a67d6f8981417fe4d5592a60a3882f454f9 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 14 Jul 2019 12:13:11 +0200 Subject: [PATCH 22/25] Object.Fetcher: Fallback to OStatus only if AP actually fails --- lib/pleroma/object/fetcher.ex | 62 ++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 82250ab8d..14454ce9d 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -31,42 +31,52 @@ def fetch_object_from_id(id, options \\ []) do {:ok, object} else Logger.info("Fetching #{id} via AP") + {status, data} = fetch_and_contain_remote_object_from_id(id) + object = Object.normalize(data, false) - with {:ok, data} <- fetch_and_contain_remote_object_from_id(id), - nil <- Object.normalize(data, false), - params <- %{ - "type" => "Create", - "to" => data["to"], - "cc" => data["cc"], - # TODO: Should we seriously keep this attributedTo thing? - "actor" => data["actor"] || data["attributedTo"], - "object" => data - }, - :ok <- Containment.contain_origin(id, params), - {:ok, activity} <- Transmogrifier.handle_incoming(params, options), - {:object, _data, %Object{} = object} <- - {:object, data, Object.normalize(activity, false)} do - {:ok, object} - else - {:error, {:reject, nil}} -> - {:reject, nil} - - {:object, data, nil} -> - reinject_object(data) - - object = %Object{} -> + if status == :ok and object == nil do + with params <- %{ + "type" => "Create", + "to" => data["to"], + "cc" => data["cc"], + # Should we seriously keep this attributedTo thing? + "actor" => data["actor"] || data["attributedTo"], + "object" => data + }, + :ok <- Containment.contain_origin(id, params), + {:ok, activity} <- Transmogrifier.handle_incoming(params, options), + {:object, _data, %Object{} = object} <- + {:object, data, Object.normalize(activity, false)} do {:ok, object} + else + {:error, {:reject, nil}} -> + {:reject, nil} - :error -> - {:error, "Object containment failed."} + {:object, data, nil} -> + reinject_object(data) - _e -> + object = %Object{} -> + {:ok, object} + + :error -> + {:error, "Object containment failed."} + + e -> + e + end + else + if status == :ok and object != nil do + {:ok, object} + else + # Only fallback when receiving a fetch/normalization error with ActivityPub Logger.info("Couldn't get object via AP, trying out OStatus fetching...") + # FIXME: OStatus Object Containment? case OStatus.fetch_activity_from_url(id) do {:ok, [activity | _]} -> {:ok, Object.normalize(activity, false)} e -> e end + end end end end From a2c601acb5e91ccfee2f38cb24ec3f86aaafc8a1 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 14 Jul 2019 14:24:56 +0200 Subject: [PATCH 23/25] FetcherTest: Containment refute called(OStatus.fetch_activity_from_url) --- test/object/fetcher_test.exs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/test/object/fetcher_test.exs b/test/object/fetcher_test.exs index 3b666e0d1..56a9d775f 100644 --- a/test/object/fetcher_test.exs +++ b/test/object/fetcher_test.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Object.FetcherTest do alias Pleroma.Object alias Pleroma.Object.Fetcher import Tesla.Mock + import Mock setup do mock(fn @@ -26,16 +27,31 @@ defmodule Pleroma.Object.FetcherTest do end describe "actor origin containment" do - test "it rejects objects with a bogus origin" do + test_with_mock "it rejects objects with a bogus origin", + Pleroma.Web.OStatus, + [:passthrough], + [] do {:error, _} = Fetcher.fetch_object_from_id("https://info.pleroma.site/activity.json") + + refute called(Pleroma.Web.OStatus.fetch_activity_from_url(:_)) end - test "it rejects objects when attributedTo is wrong (variant 1)" do + test_with_mock "it rejects objects when attributedTo is wrong (variant 1)", + Pleroma.Web.OStatus, + [:passthrough], + [] do {:error, _} = Fetcher.fetch_object_from_id("https://info.pleroma.site/activity2.json") + + refute called(Pleroma.Web.OStatus.fetch_activity_from_url(:_)) end - test "it rejects objects when attributedTo is wrong (variant 2)" do + test_with_mock "it rejects objects when attributedTo is wrong (variant 2)", + Pleroma.Web.OStatus, + [:passthrough], + [] do {:error, _} = Fetcher.fetch_object_from_id("https://info.pleroma.site/activity3.json") + + refute called(Pleroma.Web.OStatus.fetch_activity_from_url(:_)) end end From 2592934480dd704033de013491373c9dc1d173a2 Mon Sep 17 00:00:00 2001 From: "Haelwenn (lanodan) Monnier" Date: Sun, 14 Jul 2019 17:28:25 +0200 Subject: [PATCH 24/25] Object.Fetcher: Keep the with-do block as per kaniini proposition --- lib/pleroma/object/fetcher.ex | 62 +++++++++++++++-------------------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/lib/pleroma/object/fetcher.ex b/lib/pleroma/object/fetcher.ex index 14454ce9d..96b34ae9f 100644 --- a/lib/pleroma/object/fetcher.ex +++ b/lib/pleroma/object/fetcher.ex @@ -31,43 +31,36 @@ def fetch_object_from_id(id, options \\ []) do {:ok, object} else Logger.info("Fetching #{id} via AP") - {status, data} = fetch_and_contain_remote_object_from_id(id) - object = Object.normalize(data, false) - if status == :ok and object == nil do - with params <- %{ - "type" => "Create", - "to" => data["to"], - "cc" => data["cc"], - # Should we seriously keep this attributedTo thing? - "actor" => data["actor"] || data["attributedTo"], - "object" => data - }, - :ok <- Containment.contain_origin(id, params), - {:ok, activity} <- Transmogrifier.handle_incoming(params, options), - {:object, _data, %Object{} = object} <- - {:object, data, Object.normalize(activity, false)} do - {:ok, object} - else - {:error, {:reject, nil}} -> - {:reject, nil} - - {:object, data, nil} -> - reinject_object(data) - - object = %Object{} -> - {:ok, object} - - :error -> - {:error, "Object containment failed."} - - e -> - e - end + with {:fetch, {:ok, data}} <- {:fetch, fetch_and_contain_remote_object_from_id(id)}, + {:normalize, nil} <- {:normalize, Object.normalize(data, false)}, + params <- %{ + "type" => "Create", + "to" => data["to"], + "cc" => data["cc"], + # Should we seriously keep this attributedTo thing? + "actor" => data["actor"] || data["attributedTo"], + "object" => data + }, + {:containment, :ok} <- {:containment, Containment.contain_origin(id, params)}, + {:ok, activity} <- Transmogrifier.handle_incoming(params, options), + {:object, _data, %Object{} = object} <- + {:object, data, Object.normalize(activity, false)} do + {:ok, object} else - if status == :ok and object != nil do + {:containment, _} -> + {:error, "Object containment failed."} + + {:error, {:reject, nil}} -> + {:reject, nil} + + {:object, data, nil} -> + reinject_object(data) + + {:normalize, object = %Object{}} -> {:ok, object} - else + + _e -> # Only fallback when receiving a fetch/normalization error with ActivityPub Logger.info("Couldn't get object via AP, trying out OStatus fetching...") @@ -76,7 +69,6 @@ def fetch_object_from_id(id, options \\ []) do {:ok, [activity | _]} -> {:ok, Object.normalize(activity, false)} e -> e end - end end end end From 26f265fb0e22ee7b7f4e9e1df4240283f5db3a9a Mon Sep 17 00:00:00 2001 From: Ariadne Conill Date: Sun, 14 Jul 2019 16:43:00 +0000 Subject: [PATCH 25/25] remove CC-BY-NC-ND license. we moved branding assets (mascot etc) to CC-BY-SA a while back. --- CC-BY-NC-ND-4.0 | 403 ------------------------------------------------ 1 file changed, 403 deletions(-) delete mode 100644 CC-BY-NC-ND-4.0 diff --git a/CC-BY-NC-ND-4.0 b/CC-BY-NC-ND-4.0 deleted file mode 100644 index 486544290..000000000 --- a/CC-BY-NC-ND-4.0 +++ /dev/null @@ -1,403 +0,0 @@ -Attribution-NonCommercial-NoDerivatives 4.0 International - -======================================================================= - -Creative Commons Corporation ("Creative Commons") is not a law firm and -does not provide legal services or legal advice. Distribution of -Creative Commons public licenses does not create a lawyer-client or -other relationship. Creative Commons makes its licenses and related -information available on an "as-is" basis. Creative Commons gives no -warranties regarding its licenses, any material licensed under their -terms and conditions, or any related information. Creative Commons -disclaims all liability for damages resulting from their use to the -fullest extent possible. - -Using Creative Commons Public Licenses - -Creative Commons public licenses provide a standard set of terms and -conditions that creators and other rights holders may use to share -original works of authorship and other material subject to copyright -and certain other rights specified in the public license below. The -following considerations are for informational purposes only, are not -exhaustive, and do not form part of our licenses. - - Considerations for licensors: Our public licenses are - intended for use by those authorized to give the public - permission to use material in ways otherwise restricted by - copyright and certain other rights. Our licenses are - irrevocable. Licensors should read and understand the terms - and conditions of the license they choose before applying it. - Licensors should also secure all rights necessary before - applying our licenses so that the public can reuse the - material as expected. Licensors should clearly mark any - material not subject to the license. This includes other CC- - licensed material, or material used under an exception or - limitation to copyright. More considerations for licensors: - wiki.creativecommons.org/Considerations_for_licensors - - Considerations for the public: By using one of our public - licenses, a licensor grants the public permission to use the - licensed material under specified terms and conditions. If - the licensor's permission is not necessary for any reason--for - example, because of any applicable exception or limitation to - copyright--then that use is not regulated by the license. Our - licenses grant only permissions under copyright and certain - other rights that a licensor has authority to grant. Use of - the licensed material may still be restricted for other - reasons, including because others have copyright or other - rights in the material. A licensor may make special requests, - such as asking that all changes be marked or described. - Although not required by our licenses, you are encouraged to - respect those requests where reasonable. More considerations - for the public: - wiki.creativecommons.org/Considerations_for_licensees - -======================================================================= - -Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 -International Public License - -By exercising the Licensed Rights (defined below), You accept and agree -to be bound by the terms and conditions of this Creative Commons -Attribution-NonCommercial-NoDerivatives 4.0 International Public -License ("Public License"). To the extent this Public License may be -interpreted as a contract, You are granted the Licensed Rights in -consideration of Your acceptance of these terms and conditions, and the -Licensor grants You such rights in consideration of benefits the -Licensor receives from making the Licensed Material available under -these terms and conditions. - - -Section 1 -- Definitions. - - a. Adapted Material means material subject to Copyright and Similar - Rights that is derived from or based upon the Licensed Material - and in which the Licensed Material is translated, altered, - arranged, transformed, or otherwise modified in a manner requiring - permission under the Copyright and Similar Rights held by the - Licensor. For purposes of this Public License, where the Licensed - Material is a musical work, performance, or sound recording, - Adapted Material is always produced where the Licensed Material is - synched in timed relation with a moving image. - - b. Copyright and Similar Rights means copyright and/or similar rights - closely related to copyright including, without limitation, - performance, broadcast, sound recording, and Sui Generis Database - Rights, without regard to how the rights are labeled or - categorized. For purposes of this Public License, the rights - specified in Section 2(b)(1)-(2) are not Copyright and Similar - Rights. - - c. Effective Technological Measures means those measures that, in the - absence of proper authority, may not be circumvented under laws - fulfilling obligations under Article 11 of the WIPO Copyright - Treaty adopted on December 20, 1996, and/or similar international - agreements. - - d. Exceptions and Limitations means fair use, fair dealing, and/or - any other exception or limitation to Copyright and Similar Rights - that applies to Your use of the Licensed Material. - - e. Licensed Material means the artistic or literary work, database, - or other material to which the Licensor applied this Public - License. - - f. Licensed Rights means the rights granted to You subject to the - terms and conditions of this Public License, which are limited to - all Copyright and Similar Rights that apply to Your use of the - Licensed Material and that the Licensor has authority to license. - - g. Licensor means the individual(s) or entity(ies) granting rights - under this Public License. - - h. NonCommercial means not primarily intended for or directed towards - commercial advantage or monetary compensation. For purposes of - this Public License, the exchange of the Licensed Material for - other material subject to Copyright and Similar Rights by digital - file-sharing or similar means is NonCommercial provided there is - no payment of monetary compensation in connection with the - exchange. - - i. Share means to provide material to the public by any means or - process that requires permission under the Licensed Rights, such - as reproduction, public display, public performance, distribution, - dissemination, communication, or importation, and to make material - available to the public including in ways that members of the - public may access the material from a place and at a time - individually chosen by them. - - j. Sui Generis Database Rights means rights other than copyright - resulting from Directive 96/9/EC of the European Parliament and of - the Council of 11 March 1996 on the legal protection of databases, - as amended and/or succeeded, as well as other essentially - equivalent rights anywhere in the world. - - k. You means the individual or entity exercising the Licensed Rights - under this Public License. Your has a corresponding meaning. - - -Section 2 -- Scope. - - a. License grant. - - 1. Subject to the terms and conditions of this Public License, - the Licensor hereby grants You a worldwide, royalty-free, - non-sublicensable, non-exclusive, irrevocable license to - exercise the Licensed Rights in the Licensed Material to: - - a. reproduce and Share the Licensed Material, in whole or - in part, for NonCommercial purposes only; and - - b. produce and reproduce, but not Share, Adapted Material - for NonCommercial purposes only. - - 2. Exceptions and Limitations. For the avoidance of doubt, where - Exceptions and Limitations apply to Your use, this Public - License does not apply, and You do not need to comply with - its terms and conditions. - - 3. Term. The term of this Public License is specified in Section - 6(a). - - 4. Media and formats; technical modifications allowed. The - Licensor authorizes You to exercise the Licensed Rights in - all media and formats whether now known or hereafter created, - and to make technical modifications necessary to do so. The - Licensor waives and/or agrees not to assert any right or - authority to forbid You from making technical modifications - necessary to exercise the Licensed Rights, including - technical modifications necessary to circumvent Effective - Technological Measures. For purposes of this Public License, - simply making modifications authorized by this Section 2(a) - (4) never produces Adapted Material. - - 5. Downstream recipients. - - a. Offer from the Licensor -- Licensed Material. Every - recipient of the Licensed Material automatically - receives an offer from the Licensor to exercise the - Licensed Rights under the terms and conditions of this - Public License. - - b. No downstream restrictions. You may not offer or impose - any additional or different terms or conditions on, or - apply any Effective Technological Measures to, the - Licensed Material if doing so restricts exercise of the - Licensed Rights by any recipient of the Licensed - Material. - - 6. No endorsement. Nothing in this Public License constitutes or - may be construed as permission to assert or imply that You - are, or that Your use of the Licensed Material is, connected - with, or sponsored, endorsed, or granted official status by, - the Licensor or others designated to receive attribution as - provided in Section 3(a)(1)(A)(i). - - b. Other rights. - - 1. Moral rights, such as the right of integrity, are not - licensed under this Public License, nor are publicity, - privacy, and/or other similar personality rights; however, to - the extent possible, the Licensor waives and/or agrees not to - assert any such rights held by the Licensor to the limited - extent necessary to allow You to exercise the Licensed - Rights, but not otherwise. - - 2. Patent and trademark rights are not licensed under this - Public License. - - 3. To the extent possible, the Licensor waives any right to - collect royalties from You for the exercise of the Licensed - Rights, whether directly or through a collecting society - under any voluntary or waivable statutory or compulsory - licensing scheme. In all other cases the Licensor expressly - reserves any right to collect such royalties, including when - the Licensed Material is used other than for NonCommercial - purposes. - - -Section 3 -- License Conditions. - -Your exercise of the Licensed Rights is expressly made subject to the -following conditions. - - a. Attribution. - - 1. If You Share the Licensed Material, You must: - - a. retain the following if it is supplied by the Licensor - with the Licensed Material: - - i. identification of the creator(s) of the Licensed - Material and any others designated to receive - attribution, in any reasonable manner requested by - the Licensor (including by pseudonym if - designated); - - ii. a copyright notice; - - iii. a notice that refers to this Public License; - - iv. a notice that refers to the disclaimer of - warranties; - - v. a URI or hyperlink to the Licensed Material to the - extent reasonably practicable; - - b. indicate if You modified the Licensed Material and - retain an indication of any previous modifications; and - - c. indicate the Licensed Material is licensed under this - Public License, and include the text of, or the URI or - hyperlink to, this Public License. - - For the avoidance of doubt, You do not have permission under - this Public License to Share Adapted Material. - - 2. You may satisfy the conditions in Section 3(a)(1) in any - reasonable manner based on the medium, means, and context in - which You Share the Licensed Material. For example, it may be - reasonable to satisfy the conditions by providing a URI or - hyperlink to a resource that includes the required - information. - - 3. If requested by the Licensor, You must remove any of the - information required by Section 3(a)(1)(A) to the extent - reasonably practicable. - - -Section 4 -- Sui Generis Database Rights. - -Where the Licensed Rights include Sui Generis Database Rights that -apply to Your use of the Licensed Material: - - a. for the avoidance of doubt, Section 2(a)(1) grants You the right - to extract, reuse, reproduce, and Share all or a substantial - portion of the contents of the database for NonCommercial purposes - only and provided You do not Share Adapted Material; - - b. if You include all or a substantial portion of the database - contents in a database in which You have Sui Generis Database - Rights, then the database in which You have Sui Generis Database - Rights (but not its individual contents) is Adapted Material; and - - c. You must comply with the conditions in Section 3(a) if You Share - all or a substantial portion of the contents of the database. - -For the avoidance of doubt, this Section 4 supplements and does not -replace Your obligations under this Public License where the Licensed -Rights include other Copyright and Similar Rights. - - -Section 5 -- Disclaimer of Warranties and Limitation of Liability. - - a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE - EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS - AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF - ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, - IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, - WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR - PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, - ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT - KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT - ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. - - b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE - TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, - NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, - INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, - COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR - USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN - ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR - DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR - IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. - - c. The disclaimer of warranties and limitation of liability provided - above shall be interpreted in a manner that, to the extent - possible, most closely approximates an absolute disclaimer and - waiver of all liability. - - -Section 6 -- Term and Termination. - - a. This Public License applies for the term of the Copyright and - Similar Rights licensed here. However, if You fail to comply with - this Public License, then Your rights under this Public License - terminate automatically. - - b. Where Your right to use the Licensed Material has terminated under - Section 6(a), it reinstates: - - 1. automatically as of the date the violation is cured, provided - it is cured within 30 days of Your discovery of the - violation; or - - 2. upon express reinstatement by the Licensor. - - For the avoidance of doubt, this Section 6(b) does not affect any - right the Licensor may have to seek remedies for Your violations - of this Public License. - - c. For the avoidance of doubt, the Licensor may also offer the - Licensed Material under separate terms or conditions or stop - distributing the Licensed Material at any time; however, doing so - will not terminate this Public License. - - d. Sections 1, 5, 6, 7, and 8 survive termination of this Public - License. - - -Section 7 -- Other Terms and Conditions. - - a. The Licensor shall not be bound by any additional or different - terms or conditions communicated by You unless expressly agreed. - - b. Any arrangements, understandings, or agreements regarding the - Licensed Material not stated herein are separate from and - independent of the terms and conditions of this Public License. - - -Section 8 -- Interpretation. - - a. For the avoidance of doubt, this Public License does not, and - shall not be interpreted to, reduce, limit, restrict, or impose - conditions on any use of the Licensed Material that could lawfully - be made without permission under this Public License. - - b. To the extent possible, if any provision of this Public License is - deemed unenforceable, it shall be automatically reformed to the - minimum extent necessary to make it enforceable. If the provision - cannot be reformed, it shall be severed from this Public License - without affecting the enforceability of the remaining terms and - conditions. - - c. No term or condition of this Public License will be waived and no - failure to comply consented to unless expressly agreed to by the - Licensor. - - d. Nothing in this Public License constitutes or may be interpreted - as a limitation upon, or waiver of, any privileges and immunities - that apply to the Licensor or You, including from the legal - processes of any jurisdiction or authority. - -======================================================================= - -Creative Commons is not a party to its public -licenses. Notwithstanding, Creative Commons may elect to apply one of -its public licenses to material it publishes and in those instances -will be considered the “Licensor.” The text of the Creative Commons -public licenses is dedicated to the public domain under the CC0 Public -Domain Dedication. Except for the limited purpose of indicating that -material is shared under a Creative Commons public license or as -otherwise permitted by the Creative Commons policies published at -creativecommons.org/policies, Creative Commons does not authorize the -use of the trademark "Creative Commons" or any other trademark or logo -of Creative Commons without its prior written consent including, -without limitation, in connection with any unauthorized modifications -to any of its public licenses or any other arrangements, -understandings, or agreements concerning use of licensed material. For -the avoidance of doubt, this paragraph does not form part of the -public licenses. - -Creative Commons may be contacted at creativecommons.org. -