@@ -67,7 +67,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). | |||
- Mastodon API: User timelines will now respect blocks, unless you are getting the user timeline of somebody you blocked (which would be empty otherwise). | |||
- Mastodon API: Favoriting / Repeating a post multiple times will now return the identical response every time. Before, executing that action twice would return an error ("already favorited") on the second try. | |||
- Mastodon API: Limit timeline requests to 3 per timeline per 500ms per user/ip by default. | |||
- Admin API: `PATCH /api/pleroma/admin/users/:nickname/change_password` | |||
- Admin API: `PATCH /api/pleroma/admin/users/:nickname/credentials` and `GET /api/pleroma/admin/users/:nickname/credentials` | |||
</details> | |||
### Added | |||
@@ -414,12 +414,81 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret | |||
- `nicknames` | |||
- Response: none (code `204`) | |||
## `PATCH /api/pleroma/admin/users/:nickname/change_password` | |||
## `GET /api/pleroma/admin/users/:nickname/credentials` | |||
### Change the user password | |||
### Get the user's email, password, display and settings-related fields | |||
- Params: | |||
- `new_password` | |||
- `nickname` | |||
- Response: | |||
```json | |||
{ | |||
"actor_type": "Person", | |||
"allow_following_move": true, | |||
"avatar": "https://pleroma.social/media/7e8e7508fd545ef580549b6881d80ec0ff2c81ed9ad37b9bdbbdf0e0d030159d.jpg", | |||
"background": "https://pleroma.social/media/4de34c0bd10970d02cbdef8972bef0ebbf55f43cadc449554d4396156162fe9a.jpg", | |||
"banner": "https://pleroma.social/media/8d92ba2bd244b613520abf557dd448adcd30f5587022813ee9dd068945986946.jpg", | |||
"bio": "bio", | |||
"default_scope": "public", | |||
"discoverable": false, | |||
"email": "user@example.com", | |||
"fields": [ | |||
{ | |||
"name": "example", | |||
"value": "<a href=\"https://example.com\" rel=\"ugc\">https://example.com</a>" | |||
} | |||
], | |||
"hide_favorites": false, | |||
"hide_followers": false, | |||
"hide_followers_count": false, | |||
"hide_follows": false, | |||
"hide_follows_count": false, | |||
"id": "9oouHaEEUR54hls968", | |||
"locked": true, | |||
"name": "user", | |||
"no_rich_text": true, | |||
"pleroma_settings_store": {}, | |||
"raw_fields": [ | |||
{ | |||
"id": 1, | |||
"name": "example", | |||
"value": "https://example.com" | |||
}, | |||
], | |||
"show_role": true, | |||
"skip_thread_containment": false | |||
} | |||
``` | |||
## `PATCH /api/pleroma/admin/users/:nickname/credentials` | |||
### Change the user's email, password, display and settings-related fields | |||
- Params: | |||
- `email` | |||
- `password` | |||
- `name` | |||
- `bio` | |||
- `avatar` | |||
- `locked` | |||
- `no_rich_text` | |||
- `default_scope` | |||
- `banner` | |||
- `hide_follows` | |||
- `hide_followers` | |||
- `hide_followers_count` | |||
- `hide_follows_count` | |||
- `hide_favorites` | |||
- `allow_following_move` | |||
- `background` | |||
- `show_role` | |||
- `skip_thread_containment` | |||
- `fields` | |||
- `discoverable` | |||
- `actor_type` | |||
- Response: none (code `200`) | |||
## `GET /api/pleroma/admin/reports` | |||
@@ -609,11 +609,11 @@ defmodule Pleroma.ModerationLog do | |||
def get_log_entry_message(%ModerationLog{ | |||
data: %{ | |||
"actor" => %{"nickname" => actor_nickname}, | |||
"action" => "change_password", | |||
"action" => "updated_users", | |||
"subject" => subjects | |||
} | |||
}) do | |||
"@#{actor_nickname} changed password for users: #{users_to_nicknames_string(subjects)}" | |||
"@#{actor_nickname} updated users: #{users_to_nicknames_string(subjects)}" | |||
end | |||
defp nicknames_to_string(nicknames) do | |||
@@ -417,9 +417,55 @@ defmodule Pleroma.User do | |||
|> validate_format(:nickname, local_nickname_regex()) | |||
|> validate_length(:bio, max: bio_limit) | |||
|> validate_length(:name, min: 1, max: name_limit) | |||
|> put_fields() | |||
|> put_change_if_present(:bio, &{:ok, parse_bio(&1, struct)}) | |||
|> put_change_if_present(:avatar, &put_upload(&1, :avatar)) | |||
|> put_change_if_present(:banner, &put_upload(&1, :banner)) | |||
|> put_change_if_present(:background, &put_upload(&1, :background)) | |||
|> put_change_if_present( | |||
:pleroma_settings_store, | |||
&{:ok, Map.merge(struct.pleroma_settings_store, &1)} | |||
) | |||
|> validate_fields(false) | |||
end | |||
defp put_fields(changeset) do | |||
if raw_fields = get_change(changeset, :raw_fields) do | |||
raw_fields = | |||
raw_fields | |||
|> Enum.filter(fn %{"name" => n} -> n != "" end) | |||
fields = | |||
raw_fields | |||
|> Enum.map(fn f -> Map.update!(f, "value", &AutoLinker.link(&1)) end) | |||
changeset | |||
|> put_change(:raw_fields, raw_fields) | |||
|> put_change(:fields, fields) | |||
else | |||
changeset | |||
end | |||
end | |||
defp put_change_if_present(changeset, map_field, value_function) do | |||
if value = get_change(changeset, map_field) do | |||
with {:ok, new_value} <- value_function.(value) do | |||
put_change(changeset, map_field, new_value) | |||
else | |||
_ -> changeset | |||
end | |||
else | |||
changeset | |||
end | |||
end | |||
defp put_upload(value, type) do | |||
with %Plug.Upload{} <- value, | |||
{:ok, object} <- ActivityPub.upload(value, type: type) do | |||
{:ok, object.data} | |||
end | |||
end | |||
def upgrade_changeset(struct, params \\ %{}, remote? \\ false) do | |||
bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000) | |||
name_limit = Pleroma.Config.get([:instance, :user_name_length], 100) | |||
@@ -463,6 +509,27 @@ defmodule Pleroma.User do | |||
|> validate_fields(remote?) | |||
end | |||
def update_as_admin_changeset(struct, params) do | |||
struct | |||
|> update_changeset(params) | |||
|> cast(params, [:email]) | |||
|> delete_change(:also_known_as) | |||
|> unique_constraint(:email) | |||
|> validate_format(:email, @email_regex) | |||
end | |||
@spec update_as_admin(%User{}, map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} | |||
def update_as_admin(user, params) do | |||
params = Map.put(params, "password_confirmation", params["password"]) | |||
changeset = update_as_admin_changeset(user, params) | |||
if params["password"] do | |||
reset_password(user, changeset, params) | |||
else | |||
User.update_and_set_cache(changeset) | |||
end | |||
end | |||
def password_update_changeset(struct, params) do | |||
struct | |||
|> cast(params, [:password, :password_confirmation]) | |||
@@ -473,10 +540,14 @@ defmodule Pleroma.User do | |||
end | |||
@spec reset_password(User.t(), map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} | |||
def reset_password(%User{id: user_id} = user, data) do | |||
def reset_password(%User{} = user, params) do | |||
reset_password(user, user, params) | |||
end | |||
def reset_password(%User{id: user_id} = user, struct, params) do | |||
multi = | |||
Multi.new() | |||
|> Multi.update(:user, password_update_changeset(user, data)) | |||
|> Multi.update(:user, password_update_changeset(struct, params)) | |||
|> Multi.delete_all(:tokens, OAuth.Token.Query.get_by_user(user_id)) | |||
|> Multi.delete_all(:auth, OAuth.Authorization.delete_by_user_query(user)) | |||
@@ -1856,6 +1927,17 @@ defmodule Pleroma.User do | |||
def fields(%{fields: fields}), do: fields | |||
def sanitized_fields(%User{} = user) do | |||
user | |||
|> User.fields() | |||
|> Enum.map(fn %{"name" => name, "value" => value} -> | |||
%{ | |||
"name" => name, | |||
"value" => Pleroma.HTML.filter_tags(value, Pleroma.HTML.Scrubber.LinksOnly) | |||
} | |||
end) | |||
end | |||
def validate_fields(changeset, remote? \\ false) do | |||
limit_name = if remote?, do: :max_remote_account_fields, else: :max_account_fields | |||
limit = Pleroma.Config.get([:instance, limit_name], 0) | |||
@@ -38,7 +38,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do | |||
plug( | |||
OAuthScopesPlug, | |||
%{scopes: ["read:accounts"], admin: true} | |||
when action in [:list_users, :user_show, :right_get] | |||
when action in [:list_users, :user_show, :right_get, :show_user_credentials] | |||
) | |||
plug( | |||
@@ -54,7 +54,8 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do | |||
:tag_users, | |||
:untag_users, | |||
:right_add, | |||
:right_delete | |||
:right_delete, | |||
:update_user_credentials | |||
] | |||
) | |||
@@ -658,21 +659,34 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do | |||
json_response(conn, :no_content, "") | |||
end | |||
@doc "Changes password for a given user" | |||
def change_password(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname} = params) do | |||
@doc "Show a given user's credentials" | |||
def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do | |||
with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do | |||
conn | |||
|> put_view(AccountView) | |||
|> render("credentials.json", %{user: user, for: admin}) | |||
else | |||
_ -> {:error, :not_found} | |||
end | |||
end | |||
@doc "Updates a given user" | |||
def update_user_credentials( | |||
%{assigns: %{user: admin}} = conn, | |||
%{"nickname" => nickname} = params | |||
) do | |||
with {_, user} <- {:user, User.get_cached_by_nickname(nickname)}, | |||
{:ok, _user} <- | |||
User.reset_password(user, %{ | |||
password: params["new_password"], | |||
password_confirmation: params["new_password"] | |||
}) do | |||
User.update_as_admin(user, params) do | |||
ModerationLog.insert_log(%{ | |||
actor: admin, | |||
subject: [user], | |||
action: "change_password" | |||
action: "updated_users" | |||
}) | |||
User.force_password_reset_async(user) | |||
if params["password"] do | |||
User.force_password_reset_async(user) | |||
end | |||
ModerationLog.insert_log(%{ | |||
actor: admin, | |||
@@ -23,6 +23,43 @@ defmodule Pleroma.Web.AdminAPI.AccountView do | |||
} | |||
end | |||
def render("credentials.json", %{user: user, for: for_user}) do | |||
user = User.sanitize_html(user, User.html_filter_policy(for_user)) | |||
avatar = User.avatar_url(user) |> MediaProxy.url() | |||
banner = User.banner_url(user) |> MediaProxy.url() | |||
background = image_url(user.background) |> MediaProxy.url() | |||
user | |||
|> Map.take([ | |||
:id, | |||
:bio, | |||
:email, | |||
:fields, | |||
:name, | |||
:nickname, | |||
:locked, | |||
:no_rich_text, | |||
:default_scope, | |||
:hide_follows, | |||
:hide_followers_count, | |||
:hide_follows_count, | |||
:hide_followers, | |||
:hide_favorites, | |||
:allow_following_move, | |||
:show_role, | |||
:skip_thread_containment, | |||
:pleroma_settings_store, | |||
:raw_fields, | |||
:discoverable, | |||
:actor_type | |||
]) | |||
|> Map.merge(%{ | |||
"avatar" => avatar, | |||
"banner" => banner, | |||
"background" => background | |||
}) | |||
end | |||
def render("show.json", %{user: user}) do | |||
avatar = User.avatar_url(user) |> MediaProxy.url() | |||
display_name = Pleroma.HTML.strip_tags(user.name || user.nickname) | |||
@@ -104,4 +141,7 @@ defmodule Pleroma.Web.AdminAPI.AccountView do | |||
"" | |||
end | |||
end | |||
defp image_url(%{"url" => [%{"href" => href} | _]}), do: href | |||
defp image_url(_), do: nil | |||
end |
@@ -8,7 +8,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do | |||
import Pleroma.Web.ControllerHelper, | |||
only: [add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, json_response: 3] | |||
alias Pleroma.Emoji | |||
alias Pleroma.Plugs.OAuthScopesPlug | |||
alias Pleroma.Plugs.RateLimiter | |||
alias Pleroma.User | |||
@@ -140,17 +139,6 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do | |||
def update_credentials(%{assigns: %{user: original_user}} = conn, params) do | |||
user = original_user | |||
params = | |||
if Map.has_key?(params, "fields_attributes") do | |||
Map.update!(params, "fields_attributes", fn fields -> | |||
fields | |||
|> normalize_fields_attributes() | |||
|> Enum.filter(fn %{"name" => n} -> n != "" end) | |||
end) | |||
else | |||
params | |||
end | |||
user_params = | |||
[ | |||
:no_rich_text, | |||
@@ -169,46 +157,20 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do | |||
add_if_present(acc, params, to_string(key), key, &{:ok, truthy_param?(&1)}) | |||
end) | |||
|> add_if_present(params, "display_name", :name) | |||
|> add_if_present(params, "note", :bio, fn value -> {:ok, User.parse_bio(value, user)} end) | |||
|> add_if_present(params, "avatar", :avatar, fn value -> | |||
with %Plug.Upload{} <- value, | |||
{:ok, object} <- ActivityPub.upload(value, type: :avatar) do | |||
{:ok, object.data} | |||
end | |||
end) | |||
|> add_if_present(params, "header", :banner, fn value -> | |||
with %Plug.Upload{} <- value, | |||
{:ok, object} <- ActivityPub.upload(value, type: :banner) do | |||
{:ok, object.data} | |||
end | |||
end) | |||
|> add_if_present(params, "pleroma_background_image", :background, fn value -> | |||
with %Plug.Upload{} <- value, | |||
{:ok, object} <- ActivityPub.upload(value, type: :background) do | |||
{:ok, object.data} | |||
end | |||
end) | |||
|> add_if_present(params, "fields_attributes", :fields, fn fields -> | |||
fields = Enum.map(fields, fn f -> Map.update!(f, "value", &AutoLinker.link(&1)) end) | |||
{:ok, fields} | |||
end) | |||
|> add_if_present(params, "fields_attributes", :raw_fields) | |||
|> add_if_present(params, "pleroma_settings_store", :pleroma_settings_store, fn value -> | |||
{:ok, Map.merge(user.pleroma_settings_store, value)} | |||
end) | |||
|> add_if_present(params, "note", :bio) | |||
|> add_if_present(params, "avatar", :avatar) | |||
|> add_if_present(params, "header", :banner) | |||
|> add_if_present(params, "pleroma_background_image", :background) | |||
|> add_if_present( | |||
params, | |||
"fields_attributes", | |||
:raw_fields, | |||
&{:ok, normalize_fields_attributes(&1)} | |||
) | |||
|> add_if_present(params, "pleroma_settings_store", :pleroma_settings_store) | |||
|> add_if_present(params, "default_scope", :default_scope) | |||
|> add_if_present(params, "actor_type", :actor_type) | |||
emojis_text = (user_params["display_name"] || "") <> (user_params["note"] || "") | |||
user_emojis = | |||
user | |||
|> Map.get(:emoji, []) | |||
|> Enum.concat(Emoji.Formatter.get_emoji_map(emojis_text)) | |||
|> Enum.dedup() | |||
user_params = Map.put(user_params, :emoji, user_emojis) | |||
changeset = User.update_changeset(user, user_params) | |||
with {:ok, user} <- User.update_and_set_cache(changeset) do | |||
@@ -173,7 +173,8 @@ defmodule Pleroma.Web.Router do | |||
get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset) | |||
patch("/users/force_password_reset", AdminAPIController, :force_password_reset) | |||
patch("/users/:nickname/change_password", AdminAPIController, :change_password) | |||
get("/users/:nickname/credentials", AdminAPIController, :show_user_credentials) | |||
patch("/users/:nickname/credentials", AdminAPIController, :update_user_credentials) | |||
get("/users", AdminAPIController, :list_users) | |||
get("/users/:nickname", AdminAPIController, :user_show) | |||
@@ -3389,30 +3389,73 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do | |||
end | |||
end | |||
describe "PATCH /users/:nickname/change_password" do | |||
test "changes password", %{conn: conn, admin: admin} do | |||
describe "GET /users/:nickname/credentials" do | |||
test "gets the user credentials", %{conn: conn} do | |||
user = insert(:user) | |||
conn = get(conn, "/api/pleroma/admin/users/#{user.nickname}/credentials") | |||
response = assert json_response(conn, 200) | |||
assert response["email"] == user.email | |||
end | |||
test "returns 403 if requested by a non-admin" do | |||
user = insert(:user) | |||
conn = | |||
build_conn() | |||
|> assign(:user, user) | |||
|> get("/api/pleroma/admin/users/#{user.nickname}/credentials") | |||
assert json_response(conn, :forbidden) | |||
end | |||
end | |||
describe "PATCH /users/:nickname/credentials" do | |||
test "changes password and email", %{conn: conn, admin: admin} do | |||
user = insert(:user) | |||
assert user.password_reset_pending == false | |||
conn = | |||
patch(conn, "/api/pleroma/admin/users/#{user.nickname}/change_password", %{ | |||
"new_password" => "password" | |||
patch(conn, "/api/pleroma/admin/users/#{user.nickname}/credentials", %{ | |||
"password" => "new_password", | |||
"email" => "new_email@example.com", | |||
"name" => "new_name" | |||
}) | |||
assert json_response(conn, 200) == %{"status" => "success"} | |||
ObanHelpers.perform_all() | |||
assert User.get_by_id(user.id).password_reset_pending == true | |||
updated_user = User.get_by_id(user.id) | |||
[log_entry1, log_entry2] = ModerationLog |> Repo.all() |> Enum.sort() | |||
assert updated_user.email == "new_email@example.com" | |||
assert updated_user.name == "new_name" | |||
assert updated_user.password_hash != user.password_hash | |||
assert updated_user.password_reset_pending == true | |||
[log_entry2, log_entry1] = ModerationLog |> Repo.all() |> Enum.sort() | |||
assert ModerationLog.get_log_entry_message(log_entry1) == | |||
"@#{admin.nickname} changed password for users: @#{user.nickname}" | |||
"@#{admin.nickname} updated users: @#{user.nickname}" | |||
assert ModerationLog.get_log_entry_message(log_entry2) == | |||
"@#{admin.nickname} forced password reset for users: @#{user.nickname}" | |||
end | |||
test "returns 403 if requested by a non-admin" do | |||
user = insert(:user) | |||
conn = | |||
build_conn() | |||
|> assign(:user, user) | |||
|> patch("/api/pleroma/admin/users/#{user.nickname}/credentials", %{ | |||
"password" => "new_password", | |||
"email" => "new_email@example.com", | |||
"name" => "new_name" | |||
}) | |||
assert json_response(conn, :forbidden) | |||
end | |||
end | |||
describe "PATCH /users/:nickname/force_password_reset" do | |||