add bubble timeline (#100)
All checks were successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/docs Pipeline was successful
ci/woodpecker/push/release Pipeline was successful

Reviewed-on: #100
This commit is contained in:
floatingghost 2022-07-22 14:55:38 +00:00
parent 4571d372b8
commit cb6e7359af
8 changed files with 123 additions and 2 deletions

View file

@ -259,7 +259,8 @@
show_reactions: true, show_reactions: true,
password_reset_token_validity: 60 * 60 * 24, password_reset_token_validity: 60 * 60 * 24,
profile_directory: true, profile_directory: true,
privileged_staff: false privileged_staff: false,
local_bubble: []
config :pleroma, :welcome, config :pleroma, :welcome,
direct_message: [ direct_message: [

View file

@ -947,6 +947,12 @@
type: :boolean, type: :boolean,
description: description:
"Let moderators access sensitive data (e.g. updating user credentials, get password reset token, delete users, index and read private statuses)" "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)."
} }
] ]
}, },

View file

@ -1154,6 +1154,13 @@ defp restrict_instance(query, %{instance: instance}) when is_binary(instance) do
) )
end 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_instance(query, _), do: query
defp restrict_filtered(query, %{user: %User{} = user}) do defp restrict_filtered(query, %{user: %User{} = user}) do

View file

@ -75,6 +75,26 @@ def public_operation do
} }
end 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 def hashtag_operation do
%Operation{ %Operation{
tags: ["Timelines"], tags: ["Timelines"],

View file

@ -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: :home_timeline] when action == :home)
plug(RateLimiter, [name: :timeline, bucket_name: :hashtag_timeline] when action == :hashtag) 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: :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(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list)
plug( plug(
@ -125,6 +126,33 @@ def public(%{assigns: %{user: user}} = conn, params) do
end end
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 defp fail_on_bad_auth(conn) do
render_error(conn, :unauthorized, "authorization required for timeline view") render_error(conn, :unauthorized, "authorization required for timeline view")
end end

View file

@ -65,6 +65,11 @@ def websocket_handle(:pong, state) do
# We only receive pings for now # We only receive pings for now
def websocket_handle(:ping, state), do: {:ok, state} 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 def websocket_handle(frame, state) do
Logger.error("#{__MODULE__} received frame: #{inspect(frame)}") Logger.error("#{__MODULE__} received frame: #{inspect(frame)}")
{:ok, state} {:ok, state}

View file

@ -560,6 +560,7 @@ defmodule Pleroma.Web.Router do
get("/timelines/home", TimelineController, :home) get("/timelines/home", TimelineController, :home)
get("/timelines/direct", TimelineController, :direct) get("/timelines/direct", TimelineController, :direct)
get("/timelines/list/:list_id", TimelineController, :list) get("/timelines/list/:list_id", TimelineController, :list)
get("/timelines/bubble", TimelineController, :bubble)
get("/announcements", AnnouncementController, :index) get("/announcements", AnnouncementController, :index)
post("/announcements/:id/dismiss", AnnouncementController, :mark_read) post("/announcements/:id/dismiss", AnnouncementController, :mark_read)

View file

@ -994,6 +994,59 @@ test "with `%{local: true, federated: false}`, forbids unauthenticated access to
end end
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 defp create_remote_activity(user) do
obj = obj =
insert(:note, %{ insert(:note, %{