Add timeline visibility options

This commit is contained in:
FloatingGhost 2023-03-17 15:33:28 +00:00
parent fe7045632b
commit dd44387f1a
11 changed files with 90 additions and 126 deletions

View file

@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Added ## Added
- Nodeinfo keys for unauthenticated timeline visibility - Nodeinfo keys for unauthenticated timeline visibility
- Option to disable federated timeline
- Option to make the bubble timeline publicly accessible
## 2023.03 ## 2023.03

View file

@ -261,7 +261,8 @@
privileged_staff: false, privileged_staff: false,
local_bubble: [], local_bubble: [],
max_frontend_settings_json_chars: 100_000, max_frontend_settings_json_chars: 100_000,
export_prometheus_metrics: true export_prometheus_metrics: true,
federated_timeline_available: true
config :pleroma, :welcome, config :pleroma, :welcome,
direct_message: [ direct_message: [
@ -810,7 +811,7 @@
private_instance? = :if_instance_is_private private_instance? = :if_instance_is_private
config :pleroma, :restrict_unauthenticated, config :pleroma, :restrict_unauthenticated,
timelines: %{local: private_instance?, federated: private_instance?}, timelines: %{local: private_instance?, federated: private_instance?, bubble: true},
profiles: %{local: private_instance?, remote: private_instance?}, profiles: %{local: private_instance?, remote: private_instance?},
activities: %{local: private_instance?, remote: private_instance?} activities: %{local: private_instance?, remote: private_instance?}

View file

@ -969,6 +969,12 @@
key: :export_prometheus_metrics, key: :export_prometheus_metrics,
type: :boolean, type: :boolean,
description: "Enable prometheus metrics (at /api/v1/akkoma/metrics)" description: "Enable prometheus metrics (at /api/v1/akkoma/metrics)"
},
%{
key: :federated_timeline_available,
type: :boolean,
description:
"Let people view the 'firehose' feed of all public statuses from all instances."
} }
] ]
}, },

View file

@ -162,7 +162,7 @@ def local do
%Instance{ %Instance{
host: Pleroma.Web.Endpoint.host(), host: Pleroma.Web.Endpoint.host(),
favicon: Pleroma.Web.Endpoint.url() <> "/favicon.png", favicon: Pleroma.Web.Endpoint.url() <> "/favicon.png",
nodeinfo: Pleroma.Web.Nodeinfo.NodeinfoController.raw_nodeinfo() nodeinfo: Pleroma.Web.Nodeinfo.Nodeinfo.get_nodeinfo("2.1")
} }
end end

View file

@ -70,7 +70,8 @@ def public_operation do
operationId: "TimelineController.public", operationId: "TimelineController.public",
responses: %{ responses: %{
200 => Operation.response("Array of Status", "application/json", array_of_statuses()), 200 => Operation.response("Array of Status", "application/json", array_of_statuses()),
401 => Operation.response("Error", "application/json", ApiError) 401 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
} }
} }
end end

View file

@ -16,7 +16,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
alias Pleroma.Web.Plugs.RateLimiter alias Pleroma.Web.Plugs.RateLimiter
plug(Pleroma.Web.ApiSpec.CastAndValidate) plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug(:skip_public_check when action in [:public, :hashtag]) plug(:skip_public_check when action in [:public, :hashtag, :bubble])
# TODO: Replace with a macro when there is a Phoenix release with the following commit in it: # TODO: Replace with a macro when there is a Phoenix release with the following commit in it:
# https://github.com/phoenixframework/phoenix/commit/2e8c63c01fec4dde5467dbbbf9705ff9e780735e # https://github.com/phoenixframework/phoenix/commit/2e8c63c01fec4dde5467dbbbf9705ff9e780735e
@ -28,13 +28,13 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
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(RateLimiter, [name: :timeline, bucket_name: :bubble_timeline] when action == :bubble)
plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct, :bubble]) plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct])
plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list)
plug( plug(
OAuthScopesPlug, OAuthScopesPlug,
%{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} %{scopes: ["read:statuses"], fallback: :proceed_unauthenticated}
when action in [:public, :hashtag] when action in [:public, :hashtag, :bubble]
) )
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TimelineOperation defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TimelineOperation
@ -96,21 +96,19 @@ def direct(%{assigns: %{user: user}} = conn, params) do
) )
end end
defp restrict_unauthenticated?(true = _local_only) do defp restrict_unauthenticated?(type) do
Config.restrict_unauthenticated_access?(:timelines, :local) Config.restrict_unauthenticated_access?(:timelines, type)
end
defp restrict_unauthenticated?(_) do
Config.restrict_unauthenticated_access?(:timelines, :federated)
end end
# GET /api/v1/timelines/public # GET /api/v1/timelines/public
def public(%{assigns: %{user: user}} = conn, params) do def public(%{assigns: %{user: user}} = conn, params) do
local_only = params[:local] local_only = params[:local]
timeline_type = if local_only, do: :local, else: :federated
if is_nil(user) and restrict_unauthenticated?(local_only) do with {:enabled, true} <-
fail_on_bad_auth(conn) {:enabled, local_only || Config.get([:instance, :federated_timeline_available], true)},
else {:authenticated, true} <-
{:authenticated, !(is_nil(user) and restrict_unauthenticated?(timeline_type))} do
activities = activities =
params params
|> Map.put(:type, ["Create"]) |> Map.put(:type, ["Create"])
@ -131,20 +129,28 @@ def public(%{assigns: %{user: user}} = conn, params) do
as: :activity, as: :activity,
with_muted: Map.get(params, :with_muted, false) with_muted: Map.get(params, :with_muted, false)
) )
else
{:enabled, false} ->
conn
|> put_status(404)
|> json(%{error: "Federated timeline is disabled"})
{:authenticated, false} ->
fail_on_bad_auth(conn)
end end
end end
# GET /api/v1/timelines/bubble # GET /api/v1/timelines/bubble
def bubble(%{assigns: %{user: user}} = conn, params) do def bubble(%{assigns: %{user: user}} = conn, params) do
bubble_instances = if is_nil(user) and restrict_unauthenticated?(:bubble) do
Enum.uniq(
Config.get([:instance, :local_bubble], []) ++
[Pleroma.Web.Endpoint.host()]
)
if is_nil(user) do
fail_on_bad_auth(conn) fail_on_bad_auth(conn)
else else
bubble_instances =
Enum.uniq(
Config.get([:instance, :local_bubble], []) ++
[Pleroma.Web.Endpoint.host()]
)
activities = activities =
params params
|> Map.put(:type, ["Create"]) |> Map.put(:type, ["Create"])
@ -195,7 +201,7 @@ defp hashtag_fetching(params, user, local_only) do
def hashtag(%{assigns: %{user: user}} = conn, params) do def hashtag(%{assigns: %{user: user}} = conn, params) do
local_only = params[:local] local_only = params[:local]
if is_nil(user) and restrict_unauthenticated?(local_only) do if is_nil(user) and restrict_unauthenticated?(if local_only, do: :local, else: :federated) do
fail_on_bad_auth(conn) fail_on_bad_auth(conn)
else else
activities = hashtag_fetching(params, user, local_only) activities = hashtag_fetching(params, user, local_only)

View file

@ -73,9 +73,13 @@ def get_nodeinfo("2.0") do
privilegedStaff: Config.get([:instance, :privileged_staff]), privilegedStaff: Config.get([:instance, :privileged_staff]),
localBubbleInstances: Config.get([:instance, :local_bubble], []), localBubbleInstances: Config.get([:instance, :local_bubble], []),
publicTimelineVisibility: %{ publicTimelineVisibility: %{
federated: !Config.restrict_unauthenticated_access?(:timelines, :federated), federated:
local: !Config.restrict_unauthenticated_access?(:timelines, :local) !Config.restrict_unauthenticated_access?(:timelines, :federated) &&
} Config.get([:instance, :federated_timeline_available], true),
local: !Config.restrict_unauthenticated_access?(:timelines, :local),
bubble: !Config.restrict_unauthenticated_access?(:timelines, :bubble)
},
federatedTimelineAvailable: Config.get([:instance, :federated_timeline_available], true)
} }
} }
end end

View file

@ -11,6 +11,7 @@ defmodule Pleroma.Web.Nodeinfo.NodeinfoController do
alias Pleroma.Web.Federator.Publisher alias Pleroma.Web.Federator.Publisher
alias Pleroma.Web.MastodonAPI.InstanceView alias Pleroma.Web.MastodonAPI.InstanceView
alias Pleroma.Web.Endpoint alias Pleroma.Web.Endpoint
alias Pleroma.Web.Nodeinfo.Nodeinfo
def schemas(conn, _params) do def schemas(conn, _params) do
response = %{ response = %{
@ -29,105 +30,15 @@ def schemas(conn, _params) do
json(conn, response) json(conn, response)
end end
# returns a nodeinfo 2.0 map, since 2.1 just adds a repository field
# under software.
def raw_nodeinfo do
stats = Stats.get_stats()
staff_accounts =
User.all_superusers()
|> Enum.map(fn u -> u.ap_id end)
|> Enum.filter(fn u -> not Enum.member?(Config.get([:instance, :staff_transparency]), u) end)
features = InstanceView.features()
federation = InstanceView.federation()
%{
version: "2.0",
software: %{
name: Pleroma.Application.name() |> String.downcase(),
version: Pleroma.Application.version()
},
protocols: Publisher.gather_nodeinfo_protocol_names(),
services: %{
inbound: [],
outbound: []
},
openRegistrations: Config.get([:instance, :registrations_open]),
usage: %{
users: %{
total: Map.get(stats, :user_count, 0)
},
localPosts: Map.get(stats, :status_count, 0)
},
metadata: %{
nodeName: Config.get([:instance, :name]),
nodeDescription: Config.get([:instance, :description]),
private: !Config.get([:instance, :public], true),
suggestions: %{
enabled: false
},
staffAccounts: staff_accounts,
federation: federation,
pollLimits: Config.get([:instance, :poll_limits]),
postFormats: Config.get([:instance, :allowed_post_formats]),
uploadLimits: %{
general: Config.get([:instance, :upload_limit]),
avatar: Config.get([:instance, :avatar_upload_limit]),
banner: Config.get([:instance, :banner_upload_limit]),
background: Config.get([:instance, :background_upload_limit])
},
fieldsLimits: %{
maxFields: Config.get([:instance, :max_account_fields]),
maxRemoteFields: Config.get([:instance, :max_remote_account_fields]),
nameLength: Config.get([:instance, :account_field_name_length]),
valueLength: Config.get([:instance, :account_field_value_length])
},
accountActivationRequired: Config.get([:instance, :account_activation_required], false),
invitesEnabled: Config.get([:instance, :invites_enabled], false),
mailerEnabled: Config.get([Pleroma.Emails.Mailer, :enabled], false),
features: features,
restrictedNicknames: Config.get([Pleroma.User, :restricted_nicknames]),
skipThreadContainment: Config.get([:instance, :skip_thread_containment], false),
localBubbleInstances: Config.get([:instance, :local_bubble], []),
publicTimelineVisibility: %{
federated: !Config.restrict_unauthenticated_access?(:timelines, :federated),
local: !Config.restrict_unauthenticated_access?(:timelines, :local)
}
}
}
end
# Schema definition: https://github.com/jhass/nodeinfo/blob/master/schemas/2.0/schema.json # Schema definition: https://github.com/jhass/nodeinfo/blob/master/schemas/2.0/schema.json
# and https://github.com/jhass/nodeinfo/blob/master/schemas/2.1/schema.json # and https://github.com/jhass/nodeinfo/blob/master/schemas/2.1/schema.json
def nodeinfo(conn, %{"version" => "2.0"}) do def nodeinfo(conn, %{"version" => version}) when version in ["2.0", "2.1"] do
conn conn
|> put_resp_header( |> put_resp_header(
"content-type", "content-type",
"application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8"
) )
|> json(raw_nodeinfo()) |> json(Nodeinfo.get_nodeinfo(version))
end
def nodeinfo(conn, %{"version" => "2.1"}) do
raw_response = raw_nodeinfo()
updated_software =
raw_response
|> Map.get(:software)
|> Map.put(:repository, Pleroma.Application.repository())
response =
raw_response
|> Map.put(:software, updated_software)
|> Map.put(:version, "2.1")
conn
|> put_resp_header(
"content-type",
"application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.1#; charset=utf-8"
)
|> json(response)
end end
def nodeinfo(conn, _) do def nodeinfo(conn, _) do

View file

@ -598,7 +598,6 @@ 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)
@ -653,6 +652,7 @@ defmodule Pleroma.Web.Router do
get("/timelines/public", TimelineController, :public) get("/timelines/public", TimelineController, :public)
get("/timelines/tag/:tag", TimelineController, :hashtag) get("/timelines/tag/:tag", TimelineController, :hashtag)
get("/timelines/bubble", TimelineController, :bubble)
get("/polls/:id", PollController, :show) get("/polls/:id", PollController, :show)

View file

@ -408,6 +408,26 @@ test "should not return local-only posts for anonymous users" do
assert [] = result assert [] = result
end end
test "should return 404 if disabled" do
clear_config([:instance, :federated_timeline_available], false)
result =
build_conn()
|> get("/api/v1/timelines/public")
|> json_response_and_validate_schema(404)
assert %{"error" => "Federated timeline is disabled"} = result
end
test "should not return 404 if local is specified" do
clear_config([:instance, :federated_timeline_available], false)
result =
build_conn()
|> get("/api/v1/timelines/public?local=true")
|> json_response_and_validate_schema(200)
end
end end
defp local_and_remote_activities do defp local_and_remote_activities do
@ -1036,9 +1056,8 @@ test "with `%{local: true, federated: false}`, forbids unauthenticated access to
end end
describe "bubble" do describe "bubble" do
setup do: oauth_access(["read:statuses"]) test "filtering" do
%{conn: conn, user: user} = oauth_access(["read:statuses"])
test "filtering", %{conn: conn, user: user} do
clear_config([:instance, :local_bubble], []) clear_config([:instance, :local_bubble], [])
# our endpoint host has a port in it so let's set the AP ID # our endpoint host has a port in it so let's set the AP ID
local_user = insert(:user, %{ap_id: "https://localhost/users/user"}) local_user = insert(:user, %{ap_id: "https://localhost/users/user"})
@ -1060,7 +1079,7 @@ test "filtering", %{conn: conn, user: user} do
assert local_activity.id in one_instance assert local_activity.id in one_instance
# If we have others, also include theirs # If we have others, also include theirs
clear_config([:instance, :local_bubble], ["example.com"]) clear_config([:instance, :local_bubble], ["example.com"])
two_instances = two_instances =
@ -1072,6 +1091,20 @@ test "filtering", %{conn: conn, user: user} do
assert local_activity.id in two_instances assert local_activity.id in two_instances
assert remote_activity.id in two_instances assert remote_activity.id in two_instances
end end
test "restrict_unauthenticated with bubble timeline", %{conn: conn} do
clear_config([:restrict_unauthenticated, :timelines, :bubble], true)
conn
|> get("/api/v1/timelines/bubble")
|> json_response_and_validate_schema(:unauthorized)
clear_config([:restrict_unauthenticated, :timelines, :bubble], false)
conn
|> get("/api/v1/timelines/bubble")
|> json_response_and_validate_schema(200)
end
end end
defp create_remote_activity(user) do defp create_remote_activity(user) do

View file

@ -269,8 +269,8 @@ test "Represent a Service(bot) account" do
} }
with_mock( with_mock(
Pleroma.Web.Nodeinfo.NodeinfoController, Pleroma.Web.Nodeinfo.Nodeinfo,
raw_nodeinfo: fn -> %{version: "2.0"} end get_nodeinfo: fn _ -> %{version: "2.0"} end
) do ) do
assert expected == assert expected ==
AccountView.render("show.json", %{user: user, skip_visibility_check: true}) AccountView.render("show.json", %{user: user, skip_visibility_check: true})