diff --git a/config/config.exs b/config/config.exs index 0f0daf796..cfe207dcc 100644 --- a/config/config.exs +++ b/config/config.exs @@ -259,7 +259,8 @@ config :pleroma, :instance, show_reactions: true, password_reset_token_validity: 60 * 60 * 24, profile_directory: true, - privileged_staff: false + privileged_staff: false, + local_bubble: [] config :pleroma, :welcome, direct_message: [ diff --git a/config/description.exs b/config/description.exs index 1eb0a4161..f7e3c714f 100644 --- a/config/description.exs +++ b/config/description.exs @@ -947,6 +947,12 @@ config :pleroma, :config_description, [ type: :boolean, description: "Let moderators access sensitive data (e.g. updating user credentials, get password reset token, delete users, index and read private statuses)" + }, + %{ + key: :local_bubble, + type: {:list, :string}, + description: + "List of instances that make up your local bubble (closely-related instances). Used to populate the 'bubble' timeline (domain only)." } ] }, diff --git a/lib/pleroma/web/activity_pub/activity_pub.ex b/lib/pleroma/web/activity_pub/activity_pub.ex index 29055668b..3e58864c8 100644 --- a/lib/pleroma/web/activity_pub/activity_pub.ex +++ b/lib/pleroma/web/activity_pub/activity_pub.ex @@ -1154,6 +1154,13 @@ defmodule Pleroma.Web.ActivityPub.ActivityPub do ) end + defp restrict_instance(query, %{instance: instance}) when is_list(instance) do + from( + activity in query, + where: fragment("split_part(actor::text, '/'::text, 3) = ANY(?)", ^instance) + ) + end + defp restrict_instance(query, _), do: query defp restrict_filtered(query, %{user: %User{} = user}) do diff --git a/lib/pleroma/web/api_spec/operations/timeline_operation.ex b/lib/pleroma/web/api_spec/operations/timeline_operation.ex index d375c76b8..3eb6f700b 100644 --- a/lib/pleroma/web/api_spec/operations/timeline_operation.ex +++ b/lib/pleroma/web/api_spec/operations/timeline_operation.ex @@ -75,6 +75,26 @@ defmodule Pleroma.Web.ApiSpec.TimelineOperation do } end + def bubble_operation do + %Operation{ + tags: ["Timelines"], + summary: "Bubble timeline", + security: [%{"oAuth" => ["read:statuses"]}], + parameters: [ + only_media_param(), + remote_param(), + with_muted_param(), + exclude_visibilities_param(), + reply_visibility_param() | pagination_params() + ], + operationId: "TimelineController.bubble", + responses: %{ + 200 => Operation.response("Array of Status", "application/json", array_of_statuses()), + 401 => Operation.response("Error", "application/json", ApiError) + } + } + end + def hashtag_operation do %Operation{ tags: ["Timelines"], diff --git a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex index 10c279893..620026374 100644 --- a/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex +++ b/lib/pleroma/web/mastodon_api/controllers/timeline_controller.ex @@ -26,8 +26,9 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do plug(RateLimiter, [name: :timeline, bucket_name: :home_timeline] when action == :home) plug(RateLimiter, [name: :timeline, bucket_name: :hashtag_timeline] when action == :hashtag) plug(RateLimiter, [name: :timeline, bucket_name: :list_timeline] when action == :list) + plug(RateLimiter, [name: :timeline, bucket_name: :bubble_timeline] when action == :bubble) - plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct]) + plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct, :bubble]) plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) plug( @@ -125,6 +126,33 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do end end + # GET /api/v1/timelines/bubble + def bubble(%{assigns: %{user: user}} = conn, params) do + bubble_instances = Config.get([:instance, :local_bubble], []) + + if is_nil(user) do + fail_on_bad_auth(conn) + else + activities = + params + |> Map.put(:type, ["Create"]) + |> Map.put(:blocking_user, user) + |> Map.put(:muting_user, user) + |> Map.put(:reply_filtering_user, user) + |> Map.put(:instance, bubble_instances) + |> ActivityPub.fetch_public_activities() + + conn + |> add_link_headers(activities) + |> render("index.json", + activities: activities, + for: user, + as: :activity, + with_muted: Map.get(params, :with_muted, false) + ) + end + end + defp fail_on_bad_auth(conn) do render_error(conn, :unauthorized, "authorization required for timeline view") end diff --git a/lib/pleroma/web/mastodon_api/websocket_handler.ex b/lib/pleroma/web/mastodon_api/websocket_handler.ex index b978167b6..861a7ce3e 100644 --- a/lib/pleroma/web/mastodon_api/websocket_handler.ex +++ b/lib/pleroma/web/mastodon_api/websocket_handler.ex @@ -65,6 +65,11 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do # We only receive pings for now def websocket_handle(:ping, state), do: {:ok, state} + def websocket_handle({:text, "ping"}, state) do + if state.timer, do: Process.cancel_timer(state.timer) + {:reply, {:text, "pong"}, %{state | timer: timer()}} + end + def websocket_handle(frame, state) do Logger.error("#{__MODULE__} received frame: #{inspect(frame)}") {:ok, state} diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index d413835bb..a0310bbb5 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -560,6 +560,7 @@ defmodule Pleroma.Web.Router do get("/timelines/home", TimelineController, :home) get("/timelines/direct", TimelineController, :direct) get("/timelines/list/:list_id", TimelineController, :list) + get("/timelines/bubble", TimelineController, :bubble) get("/announcements", AnnouncementController, :index) post("/announcements/:id/dismiss", AnnouncementController, :mark_read) diff --git a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs index 187982d92..7ef08f258 100644 --- a/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs +++ b/test/pleroma/web/mastodon_api/controllers/timeline_controller_test.exs @@ -994,6 +994,59 @@ defmodule Pleroma.Web.MastodonAPI.TimelineControllerTest do end end + describe "bubble" do + setup do: oauth_access(["read:statuses"]) + + test "it returns nothing if no bubble is configured", %{user: user, conn: conn} do + clear_config([:instance, :local_bubble], []) + {:ok, _} = CommonAPI.post(user, %{status: ".", visibility: "public"}) + + conn = get(conn, "/api/v1/timelines/bubble") + + assert [] = json_response_and_validate_schema(conn, :ok) + end + + test "filtering", %{conn: conn, user: user} do + clear_config([:instance, :local_bubble], []) + local_user = insert(:user) + remote_user = insert(:user, %{ap_id: "https://example.com/users/remote_user"}) + {:ok, user, local_user} = User.follow(user, local_user) + {:ok, _user, remote_user} = User.follow(user, remote_user) + + {:ok, local_activity} = CommonAPI.post(local_user, %{status: "Status"}) + remote_activity = create_remote_activity(remote_user) + + resp = + conn + |> get("/api/v1/timelines/bubble") + |> json_response_and_validate_schema(200) + |> Enum.map(& &1["id"]) + + assert Enum.empty?(resp) + + clear_config([:instance, :local_bubble], ["localhost:4001"]) + + one_instance = + conn + |> get("/api/v1/timelines/bubble") + |> json_response_and_validate_schema(200) + |> Enum.map(& &1["id"]) + + assert local_activity.id in one_instance + + clear_config([:instance, :local_bubble], ["localhost:4001", "example.com"]) + + two_instances = + conn + |> get("/api/v1/timelines/bubble") + |> json_response_and_validate_schema(200) + |> Enum.map(& &1["id"]) + + assert local_activity.id in two_instances + assert remote_activity.id in two_instances + end + end + defp create_remote_activity(user) do obj = insert(:note, %{