c22ecac567
Implements the preferences endpoint in the Mastodon API, but returns default values for most of the preferences right now. The only supported preference we can access is default post visibility, and a relevant test is added as well.
552 lines
18 KiB
Elixir
552 lines
18 KiB
Elixir
# Pleroma: A lightweight social networking server
|
|
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
defmodule Pleroma.Web.MastodonAPI.AccountController do
|
|
use Pleroma.Web, :controller
|
|
|
|
import Pleroma.Web.ControllerHelper,
|
|
only: [
|
|
add_link_headers: 2,
|
|
assign_account_by_id: 2,
|
|
embed_relationships?: 1,
|
|
json_response: 3
|
|
]
|
|
|
|
alias Pleroma.Maps
|
|
alias Pleroma.User
|
|
alias Pleroma.UserNote
|
|
alias Pleroma.Web.ActivityPub.ActivityPub
|
|
alias Pleroma.Web.ActivityPub.Builder
|
|
alias Pleroma.Web.ActivityPub.Pipeline
|
|
alias Pleroma.Web.CommonAPI
|
|
alias Pleroma.Web.MastodonAPI.ListView
|
|
alias Pleroma.Web.MastodonAPI.MastodonAPI
|
|
alias Pleroma.Web.MastodonAPI.MastodonAPIController
|
|
alias Pleroma.Web.MastodonAPI.StatusView
|
|
alias Pleroma.Web.OAuth.OAuthController
|
|
alias Pleroma.Web.Plugs.OAuthScopesPlug
|
|
alias Pleroma.Web.Plugs.RateLimiter
|
|
alias Pleroma.Web.TwitterAPI.TwitterAPI
|
|
alias Pleroma.Web.Utils.Params
|
|
|
|
plug(Pleroma.Web.ApiSpec.CastAndValidate)
|
|
|
|
plug(:skip_auth when action in [:create])
|
|
|
|
plug(:skip_public_check when action in [:show, :statuses])
|
|
|
|
plug(
|
|
OAuthScopesPlug,
|
|
%{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]}
|
|
when action in [:show, :followers, :following, :lookup]
|
|
)
|
|
|
|
plug(
|
|
OAuthScopesPlug,
|
|
%{fallback: :proceed_unauthenticated, scopes: ["read:statuses"]}
|
|
when action == :statuses
|
|
)
|
|
|
|
plug(
|
|
OAuthScopesPlug,
|
|
%{scopes: ["read:accounts"]}
|
|
when action in [:verify_credentials, :endorsements, :identity_proofs, :preferences]
|
|
)
|
|
|
|
plug(
|
|
OAuthScopesPlug,
|
|
%{scopes: ["write:accounts"]}
|
|
when action in [:update_credentials, :note]
|
|
)
|
|
|
|
plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :lists)
|
|
|
|
plug(
|
|
OAuthScopesPlug,
|
|
%{scopes: ["follow", "read:blocks"]} when action == :blocks
|
|
)
|
|
|
|
plug(
|
|
OAuthScopesPlug,
|
|
%{scopes: ["follow", "write:blocks"]} when action in [:block, :unblock]
|
|
)
|
|
|
|
plug(OAuthScopesPlug, %{scopes: ["read:follows"]} when action == :relationships)
|
|
|
|
plug(
|
|
OAuthScopesPlug,
|
|
%{scopes: ["follow", "write:follows"]}
|
|
when action in [:follow_by_uri, :follow, :unfollow, :remove_from_followers]
|
|
)
|
|
|
|
plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes)
|
|
|
|
plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute])
|
|
|
|
@relationship_actions [:follow, :unfollow, :remove_from_followers]
|
|
@needs_account ~W(followers following lists follow unfollow mute unmute block unblock note remove_from_followers)a
|
|
|
|
plug(
|
|
RateLimiter,
|
|
[name: :relation_id_action, params: [:id, :uri]] when action in @relationship_actions
|
|
)
|
|
|
|
plug(RateLimiter, [name: :relations_actions] when action in @relationship_actions)
|
|
plug(RateLimiter, [name: :app_account_creation] when action == :create)
|
|
plug(:assign_account_by_id when action in @needs_account)
|
|
|
|
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
|
|
|
|
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.AccountOperation
|
|
|
|
@doc "POST /api/v1/accounts"
|
|
def create(%{assigns: %{app: app}, body_params: params} = conn, _params) do
|
|
with :ok <- validate_email_param(params),
|
|
:ok <- TwitterAPI.validate_captcha(app, params),
|
|
{:ok, user} <- TwitterAPI.register_user(params),
|
|
{_, {:ok, token}} <-
|
|
{:login, OAuthController.login(user, app, app.scopes)} do
|
|
OAuthController.after_token_exchange(conn, %{user: user, token: token})
|
|
else
|
|
{:login, {:account_status, :confirmation_pending}} ->
|
|
json_response(conn, :ok, %{
|
|
message: "You have been registered. Please check your email for further instructions.",
|
|
identifier: "missing_confirmed_email"
|
|
})
|
|
|
|
{:login, {:account_status, :approval_pending}} ->
|
|
json_response(conn, :ok, %{
|
|
message:
|
|
"You have been registered. You'll be able to log in once your account is approved.",
|
|
identifier: "awaiting_approval"
|
|
})
|
|
|
|
{:login, _} ->
|
|
json_response(conn, :ok, %{
|
|
message:
|
|
"You have been registered. Some post-registration steps may be pending. " <>
|
|
"Please log in manually.",
|
|
identifier: "manual_login_required"
|
|
})
|
|
|
|
{:error, error} ->
|
|
json_response(conn, :bad_request, %{error: error})
|
|
end
|
|
end
|
|
|
|
def create(%{assigns: %{app: _app}} = conn, _) do
|
|
render_error(conn, :bad_request, "Missing parameters")
|
|
end
|
|
|
|
def create(conn, _) do
|
|
render_error(conn, :forbidden, "Invalid credentials")
|
|
end
|
|
|
|
defp validate_email_param(%{email: email}) when not is_nil(email), do: :ok
|
|
|
|
defp validate_email_param(_) do
|
|
case Pleroma.Config.get([:instance, :account_activation_required]) do
|
|
true -> {:error, dgettext("errors", "Missing parameter: %{name}", name: "email")}
|
|
_ -> :ok
|
|
end
|
|
end
|
|
|
|
@doc "GET /api/v1/accounts/verify_credentials"
|
|
def verify_credentials(%{assigns: %{user: user}} = conn, _) do
|
|
render(conn, "show.json",
|
|
user: user,
|
|
for: user,
|
|
with_pleroma_settings: true
|
|
)
|
|
end
|
|
|
|
@doc "PATCH /api/v1/accounts/update_credentials"
|
|
def update_credentials(%{assigns: %{user: user}, body_params: params} = conn, _params) do
|
|
params =
|
|
params
|
|
|> Enum.filter(fn {_, value} -> not is_nil(value) end)
|
|
|> Enum.into(%{})
|
|
|
|
# We use an empty string as a special value to reset
|
|
# avatars, banners, backgrounds
|
|
user_image_value = fn
|
|
"" -> {:ok, nil}
|
|
value -> {:ok, value}
|
|
end
|
|
|
|
status_ttl_days_value = fn
|
|
-1 -> {:ok, nil}
|
|
value -> {:ok, value}
|
|
end
|
|
|
|
user_params =
|
|
[
|
|
:no_rich_text,
|
|
:hide_followers_count,
|
|
:hide_follows_count,
|
|
:hide_followers,
|
|
:hide_follows,
|
|
:hide_favorites,
|
|
:show_role,
|
|
:skip_thread_containment,
|
|
:allow_following_move,
|
|
:also_known_as
|
|
]
|
|
|> Enum.reduce(%{}, fn key, acc ->
|
|
Maps.put_if_present(acc, key, params[key], &{:ok, Params.truthy_param?(&1)})
|
|
end)
|
|
|> Maps.put_if_present(:name, params[:display_name])
|
|
|> Maps.put_if_present(:bio, params[:note])
|
|
|> Maps.put_if_present(:raw_bio, params[:note])
|
|
|> Maps.put_if_present(:avatar, params[:avatar], user_image_value)
|
|
|> Maps.put_if_present(:banner, params[:header], user_image_value)
|
|
|> Maps.put_if_present(:background, params[:pleroma_background_image], user_image_value)
|
|
|> Maps.put_if_present(
|
|
:raw_fields,
|
|
params[:fields_attributes],
|
|
&{:ok, normalize_fields_attributes(&1)}
|
|
)
|
|
|> Maps.put_if_present(:pleroma_settings_store, params[:pleroma_settings_store])
|
|
|> Maps.put_if_present(:default_scope, params[:default_scope])
|
|
|> Maps.put_if_present(:default_scope, params["source"]["privacy"])
|
|
|> Maps.put_if_present(:actor_type, params[:bot], fn bot ->
|
|
if bot, do: {:ok, "Service"}, else: {:ok, "Person"}
|
|
end)
|
|
|> Maps.put_if_present(:actor_type, params[:actor_type])
|
|
|> Maps.put_if_present(:also_known_as, params[:also_known_as])
|
|
# Note: param name is indeed :locked (not an error)
|
|
|> Maps.put_if_present(:is_locked, params[:locked])
|
|
# Note: param name is indeed :discoverable (not an error)
|
|
|> Maps.put_if_present(:is_discoverable, params[:discoverable])
|
|
|> Maps.put_if_present(:language, Pleroma.Web.Gettext.normalize_locale(params[:language]))
|
|
|> Maps.put_if_present(:status_ttl_days, params[:status_ttl_days], status_ttl_days_value)
|
|
|> Maps.put_if_present(:accepts_direct_messages_from, params[:accepts_direct_messages_from])
|
|
|
|
# What happens here:
|
|
#
|
|
# We want to update the user through the pipeline, but the ActivityPub
|
|
# update information is not quite enough for this, because this also
|
|
# contains local settings that don't federate and don't even appear
|
|
# in the Update activity.
|
|
#
|
|
# So we first build the normal local changeset, then apply it to the
|
|
# user data, but don't persist it. With this, we generate the object
|
|
# data for our update activity. We feed this and the changeset as meta
|
|
# inforation into the pipeline, where they will be properly updated and
|
|
# federated.
|
|
with changeset <- User.update_changeset(user, user_params),
|
|
{:ok, unpersisted_user} <- Ecto.Changeset.apply_action(changeset, :update),
|
|
updated_object <-
|
|
Pleroma.Web.ActivityPub.UserView.render("user.json", user: unpersisted_user)
|
|
|> Map.delete("@context"),
|
|
{:ok, update_data, []} <- Builder.update(user, updated_object),
|
|
{:ok, _update, _} <-
|
|
Pipeline.common_pipeline(update_data,
|
|
local: true,
|
|
user_update_changeset: changeset
|
|
) do
|
|
render(conn, "show.json",
|
|
user: unpersisted_user,
|
|
for: unpersisted_user,
|
|
with_pleroma_settings: true
|
|
)
|
|
else
|
|
{:error, %Ecto.Changeset{errors: [avatar: {"file is too large", _}]}} ->
|
|
render_error(conn, :request_entity_too_large, "File is too large")
|
|
|
|
{:error, %Ecto.Changeset{errors: [banner: {"file is too large", _}]}} ->
|
|
render_error(conn, :request_entity_too_large, "File is too large")
|
|
|
|
{:error, %Ecto.Changeset{errors: [background: {"file is too large", _}]}} ->
|
|
render_error(conn, :request_entity_too_large, "File is too large")
|
|
|
|
_e ->
|
|
render_error(conn, :forbidden, "Invalid request")
|
|
end
|
|
end
|
|
|
|
defp normalize_fields_attributes(fields) do
|
|
if Enum.all?(fields, &is_tuple/1) do
|
|
Enum.map(fields, fn {_, v} -> v end)
|
|
else
|
|
Enum.map(fields, fn
|
|
%{} = field -> %{"name" => field.name, "value" => field.value}
|
|
field -> field
|
|
end)
|
|
end
|
|
end
|
|
|
|
@doc "GET /api/v1/accounts/relationships"
|
|
def relationships(%{assigns: %{user: user}} = conn, %{id: id}) do
|
|
targets = User.get_all_by_ids(List.wrap(id))
|
|
|
|
render(conn, "relationships.json", user: user, targets: targets)
|
|
end
|
|
|
|
# Instead of returning a 400 when no "id" params is present, Mastodon returns an empty array.
|
|
def relationships(%{assigns: %{user: _user}} = conn, _), do: json(conn, [])
|
|
|
|
@doc "GET /api/v1/accounts/:id"
|
|
def show(%{assigns: %{user: for_user}} = conn, %{id: nickname_or_id} = params) do
|
|
with %User{} = user <- User.get_cached_by_nickname_or_id(nickname_or_id, for: for_user),
|
|
:visible <- User.visible_for(user, for_user) do
|
|
render(conn, "show.json",
|
|
user: user,
|
|
for: for_user,
|
|
embed_relationships: embed_relationships?(params)
|
|
)
|
|
else
|
|
error -> user_visibility_error(conn, error)
|
|
end
|
|
end
|
|
|
|
@doc "GET /api/v1/accounts/:id/statuses"
|
|
def statuses(%{assigns: %{user: reading_user}} = conn, params) do
|
|
with %User{} = user <- User.get_cached_by_nickname_or_id(params.id, for: reading_user),
|
|
:visible <- User.visible_for(user, reading_user) do
|
|
params =
|
|
params
|
|
|> Map.delete(:tagged)
|
|
|> Map.put(:tag, params[:tagged])
|
|
|
|
activities = ActivityPub.fetch_user_activities(user, reading_user, params)
|
|
|
|
conn
|
|
|> add_link_headers(activities)
|
|
|> put_view(StatusView)
|
|
|> render("index.json",
|
|
activities: activities,
|
|
for: reading_user,
|
|
as: :activity,
|
|
with_muted: Map.get(params, :with_muted, false)
|
|
)
|
|
else
|
|
error -> user_visibility_error(conn, error)
|
|
end
|
|
end
|
|
|
|
defp user_visibility_error(conn, error) do
|
|
case error do
|
|
:restrict_unauthenticated ->
|
|
render_error(conn, :unauthorized, "This API requires an authenticated user")
|
|
|
|
_ ->
|
|
render_error(conn, :not_found, "Can't find user")
|
|
end
|
|
end
|
|
|
|
@doc "GET /api/v1/accounts/:id/followers"
|
|
def followers(%{assigns: %{user: for_user, account: user}} = conn, params) do
|
|
params =
|
|
params
|
|
|> Enum.map(fn {key, value} -> {to_string(key), value} end)
|
|
|> Enum.into(%{})
|
|
|
|
followers =
|
|
cond do
|
|
for_user && user.id == for_user.id -> MastodonAPI.get_followers(user, params)
|
|
user.hide_followers -> []
|
|
true -> MastodonAPI.get_followers(user, params)
|
|
end
|
|
|
|
conn
|
|
|> add_link_headers(followers)
|
|
# https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223
|
|
|> render("index.json",
|
|
for: for_user,
|
|
users: followers,
|
|
as: :user,
|
|
embed_relationships: embed_relationships?(params)
|
|
)
|
|
end
|
|
|
|
@doc "GET /api/v1/accounts/:id/following"
|
|
def following(%{assigns: %{user: for_user, account: user}} = conn, params) do
|
|
params =
|
|
params
|
|
|> Enum.map(fn {key, value} -> {to_string(key), value} end)
|
|
|> Enum.into(%{})
|
|
|
|
followers =
|
|
cond do
|
|
for_user && user.id == for_user.id -> MastodonAPI.get_friends(user, params)
|
|
user.hide_follows -> []
|
|
true -> MastodonAPI.get_friends(user, params)
|
|
end
|
|
|
|
conn
|
|
|> add_link_headers(followers)
|
|
# https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838#note_59223
|
|
|> render("index.json",
|
|
for: for_user,
|
|
users: followers,
|
|
as: :user,
|
|
embed_relationships: embed_relationships?(params)
|
|
)
|
|
end
|
|
|
|
@doc "GET /api/v1/accounts/:id/lists"
|
|
def lists(%{assigns: %{user: user, account: account}} = conn, _params) do
|
|
lists = Pleroma.List.get_lists_account_belongs(user, account)
|
|
|
|
conn
|
|
|> put_view(ListView)
|
|
|> render("index.json", lists: lists)
|
|
end
|
|
|
|
@doc "POST /api/v1/accounts/:id/follow"
|
|
def follow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
|
|
{:error, "Can not follow yourself"}
|
|
end
|
|
|
|
def follow(%{body_params: params, assigns: %{user: follower, account: followed}} = conn, _) do
|
|
with {:ok, follower} <- MastodonAPI.follow(follower, followed, params) do
|
|
render(conn, "relationship.json", user: follower, target: followed)
|
|
else
|
|
{:error, message} -> json_response(conn, :forbidden, %{error: message})
|
|
end
|
|
end
|
|
|
|
@doc "POST /api/v1/accounts/:id/unfollow"
|
|
def unfollow(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
|
|
{:error, "Can not unfollow yourself"}
|
|
end
|
|
|
|
def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) do
|
|
with {:ok, follower} <- CommonAPI.unfollow(follower, followed) do
|
|
render(conn, "relationship.json", user: follower, target: followed)
|
|
end
|
|
end
|
|
|
|
@doc "POST /api/v1/accounts/:id/mute"
|
|
def mute(%{assigns: %{user: muter, account: muted}, body_params: params} = conn, _params) do
|
|
with {:ok, _user_relationships} <- User.mute(muter, muted, params) do
|
|
render(conn, "relationship.json", user: muter, target: muted)
|
|
else
|
|
{:error, message} -> json_response(conn, :forbidden, %{error: message})
|
|
end
|
|
end
|
|
|
|
@doc "POST /api/v1/accounts/:id/unmute"
|
|
def unmute(%{assigns: %{user: muter, account: muted}} = conn, _params) do
|
|
with {:ok, _user_relationships} <- User.unmute(muter, muted) do
|
|
render(conn, "relationship.json", user: muter, target: muted)
|
|
else
|
|
{:error, message} -> json_response(conn, :forbidden, %{error: message})
|
|
end
|
|
end
|
|
|
|
@doc "POST /api/v1/accounts/:id/block"
|
|
def block(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
|
|
with {:ok, _activity} <- CommonAPI.block(blocker, blocked) do
|
|
render(conn, "relationship.json", user: blocker, target: blocked)
|
|
else
|
|
{:error, message} -> json_response(conn, :forbidden, %{error: message})
|
|
end
|
|
end
|
|
|
|
@doc "POST /api/v1/accounts/:id/unblock"
|
|
def unblock(%{assigns: %{user: blocker, account: blocked}} = conn, _params) do
|
|
with {:ok, _activity} <- CommonAPI.unblock(blocker, blocked) do
|
|
render(conn, "relationship.json", user: blocker, target: blocked)
|
|
else
|
|
{:error, message} -> json_response(conn, :forbidden, %{error: message})
|
|
end
|
|
end
|
|
|
|
@doc "POST /api/v1/accounts/:id/note"
|
|
def note(
|
|
%{assigns: %{user: noter, account: target}, body_params: %{comment: comment}} = conn,
|
|
_params
|
|
) do
|
|
with {:ok, _user_note} <- UserNote.create(noter, target, comment) do
|
|
render(conn, "relationship.json", user: noter, target: target)
|
|
end
|
|
end
|
|
|
|
@doc "POST /api/v1/accounts/:id/remove_from_followers"
|
|
def remove_from_followers(%{assigns: %{user: %{id: id}, account: %{id: id}}}, _params) do
|
|
{:error, "Can not unfollow yourself"}
|
|
end
|
|
|
|
def remove_from_followers(%{assigns: %{user: followed, account: follower}} = conn, _params) do
|
|
with {:ok, follower} <- CommonAPI.reject_follow_request(follower, followed) do
|
|
render(conn, "relationship.json", user: followed, target: follower)
|
|
else
|
|
nil ->
|
|
render_error(conn, :not_found, "Record not found")
|
|
end
|
|
end
|
|
|
|
@doc "POST /api/v1/follows"
|
|
def follow_by_uri(%{body_params: %{uri: uri}} = conn, _) do
|
|
case User.get_cached_by_nickname(uri) do
|
|
%User{} = user ->
|
|
conn
|
|
|> assign(:account, user)
|
|
|> follow(%{})
|
|
|
|
nil ->
|
|
{:error, :not_found}
|
|
end
|
|
end
|
|
|
|
@doc "GET /api/v1/mutes"
|
|
def mutes(%{assigns: %{user: user}} = conn, params) do
|
|
users =
|
|
user
|
|
|> User.muted_users_relation(_restrict_deactivated = true)
|
|
|> Pleroma.Pagination.fetch_paginated(params)
|
|
|
|
conn
|
|
|> add_link_headers(users)
|
|
|> render("index.json",
|
|
users: users,
|
|
for: user,
|
|
as: :user,
|
|
embed_relationships: embed_relationships?(params)
|
|
)
|
|
end
|
|
|
|
@doc "GET /api/v1/blocks"
|
|
def blocks(%{assigns: %{user: user}} = conn, params) do
|
|
users =
|
|
user
|
|
|> User.blocked_users_relation(_restrict_deactivated = true)
|
|
|> Pleroma.Pagination.fetch_paginated(params)
|
|
|
|
conn
|
|
|> add_link_headers(users)
|
|
|> render("index.json",
|
|
users: users,
|
|
for: user,
|
|
as: :user,
|
|
embed_relationships: embed_relationships?(params)
|
|
)
|
|
end
|
|
|
|
@doc "GET /api/v1/accounts/lookup"
|
|
def lookup(%{assigns: %{user: for_user}} = conn, %{acct: nickname} = _params) do
|
|
with %User{} = user <- User.get_by_nickname(nickname),
|
|
:visible <- User.visible_for(user, for_user) do
|
|
render(conn, "show.json",
|
|
user: user,
|
|
skip_visibility_check: true
|
|
)
|
|
else
|
|
error -> user_visibility_error(conn, error)
|
|
end
|
|
end
|
|
|
|
@doc "GET /api/v1/endorsements"
|
|
def endorsements(conn, params), do: MastodonAPIController.empty_array(conn, params)
|
|
|
|
@doc "GET /api/v1/identity_proofs"
|
|
def identity_proofs(conn, params), do: MastodonAPIController.empty_array(conn, params)
|
|
|
|
@doc "GET /api/v1/preferences"
|
|
def preferences(%{assigns: %{user: user}} = conn, params) do
|
|
render(conn, "preferences.json", user: user)
|
|
end
|
|
end
|