includes/abstracts/class-llms-rest-users-controller.php
<?php
/**
* Base users controller class
*
* @package LifterLMS_REST/Abstracts
*
* @since 1.0.0-beta.1
* @version 1.0.0-beta.27
*/
defined( 'ABSPATH' ) || exit;
/**
* LLMS_REST_Users_Controller class
*
* @since 1.0.0-beta.1
* @since 1.0.0-beta.7 Added `check_read_object_permissions()` method override.
* @since 1.0.0-beta.10 Fixed setting roles instead of appending them when updating user.
* @since 1.0.0-beta.11 Correctly map request's `billing_postcode` param to `billing_zip` meta.
* @since 1.0.0-beta.12 Add `search` and `search_columns` collection filtering.
* @since 1.0.0-beta.14 Only add remapped keys to the response when the schema key is present in the expected response fields array.
*/
abstract class LLMS_REST_Users_Controller extends LLMS_Rest_Controller {
/**
* Resource ID or Name
*
* For example: 'student' or 'instructor'.
*
* @var string
*/
protected $resource_name;
/**
* Schema properties available for ordering the collection
*
* @var string[]
*/
protected $orderby_properties = array(
'id',
'email',
'name',
'registered_date',
);
/**
* Whether search is allowed
*
* @var boolean
*/
protected $is_searchable = true;
/**
* Schema properties to query search columns mapping
*
* @var array
*/
protected $search_columns_mapping = array(
'id' => 'ID',
'username' => 'user_login',
'email' => 'user_email',
'url' => 'user_url',
'name' => 'display_name',
);
/**
* Constructor.
*
* @since 1.0.0-beta.27
*
* @return void
*/
public function __construct() {
$this->meta = new WP_REST_User_Meta_Fields();
}
/**
* Determine if the current user has permissions to manage the role(s) present in a request
*
* @since 1.0.0-beta.1
*
* @param WP_REST_Request $request Request object.
* @return true|WP_Error
*/
protected function check_roles_permissions( $request ) {
global $wp_roles;
$schema = $this->get_item_schema();
$roles = array();
if ( ! empty( $request['roles'] ) ) {
$roles = $request['roles'];
} elseif ( ! empty( $schema['properties']['roles']['default'] ) ) {
$roles = $schema['properties']['roles']['default'];
}
foreach ( $roles as $role ) {
if ( ! isset( $wp_roles->role_objects[ $role ] ) ) {
// Translators: %s = role key.
return llms_rest_bad_request_error( sprintf( __( 'The role %s does not exist.', 'lifterlms' ), $role ) );
}
$potential_role = $wp_roles->role_objects[ $role ];
/*
* Don't let anyone with 'edit_users' (admins) edit their own role to something without it.
* Multisite super admins can freely edit their blog roles -- they possess all caps.
*/
if ( ! ( is_multisite()
&& current_user_can( 'manage_sites' ) )
&& get_current_user_id() === $request['id']
&& ! $potential_role->has_cap( 'edit_users' )
) {
return llms_rest_authorization_required_error( __( 'You are not allowed to give users this role.', 'lifterlms' ) );
}
// Include admin functions to get access to `get_editable_roles()`.
require_once ABSPATH . 'wp-admin/includes/admin.php';
// The new role must be editable by the logged-in user.
$editable_roles = get_editable_roles();
if ( empty( $editable_roles[ $role ] ) ) {
return llms_rest_authorization_required_error( __( 'You are not allowed to give users this role.', 'lifterlms' ) );
}
}
return true;
}
/**
* Insert the prepared data into the database
*
* @since 1.0.0-beta.1
*
* @param array $prepared Prepared item data.
* @param WP_REST_Request $request Request object.
* @return obj Object Instance of object from `$this->get_object()`.
*/
protected function create_object( $prepared, $request ) {
$object_id = wp_insert_user( $prepared );
if ( is_wp_error( $object_id ) ) {
return $object_id;
}
return $this->update_additional_data( $object_id, $prepared, $request );
}
/**
* Delete the object
*
* Note: we do not return 404s when the resource to delete cannot be found. We assume it's already been deleted and respond with 204.
* Errors returned by this method should be any error other than a 404!
*
* @since 1.0.0-beta.1
*
* @param obj $object Instance of the object from `$this->get_object()`.
* @param WP_REST_Request $request Request object.
* @return true|WP_Error `true` when the object is removed, `WP_Error` on failure.
*/
protected function delete_object( $object, $request ) {
$id = $object->get( 'id' );
$reassign = 0 === $request['reassign'] ? null : $request['reassign'];
if ( ! empty( $reassign ) ) {
if ( $reassign === $id || ! get_userdata( $reassign ) ) {
return llms_rest_bad_request_error( __( 'Invalid user ID for reassignment.', 'lifterlms' ) );
}
}
// Include admin user functions to get access to `wp_delete_user()`.
require_once ABSPATH . 'wp-admin/includes/user.php';
$result = wp_delete_user( $id, $reassign );
if ( ! $result ) {
return llms_rest_server_error( __( 'The user could not be deleted.', 'lifterlms' ) );
}
return true;
}
/**
* Determine if the current user can view the object
*
* @since 1.0.0-beta.7
*
* @param object $object Object.
* @return bool
*/
protected function check_read_object_permissions( $object ) {
return $this->check_read_item_permissions( $this->get_object_id( $object ) );
}
/**
* Retrieves the query params for the objects collection
*
* @since 1.0.0-beta.1
*
* @return array Collection parameters.
*/
public function get_collection_params() {
$params = parent::get_collection_params();
$params['roles'] = array(
'description' => __( 'Include only users keys matching matching a specific role. Accepts a single role or a comma separated list of roles.', 'lifterlms' ),
'type' => 'array',
'items' => array(
'type' => 'string',
'enum' => $this->get_enum_roles(),
),
);
return $params;
}
/**
* Retrieve arguments for deleting a resource
*
* @since 1.0.0-beta.1
*
* @return array
*/
public function get_delete_item_args() {
return array(
'reassign' => array(
'type' => 'integer',
'description' => __( 'Reassign the deleted user\'s posts and links to this user ID.', 'lifterlms' ),
'default' => 0,
'sanitize_callback' => 'absint',
),
);
}
/**
* Retrieve an array of allowed user role values.
*
* @since 1.0.0-beta.1
*
* @return string[]
*/
protected function get_enum_roles() {
global $wp_roles;
return array_keys( $wp_roles->roles );
}
/**
* Get the item schema base.
*
* @since 1.0.0-beta.27
*
* @return array
*/
protected function get_item_schema_base() {
$schema = array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => $this->resource_name,
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'Unique identifier for the user.', 'lifterlms' ),
'type' => 'integer',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'username' => array(
'description' => __( 'Login name for the user.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'edit' ),
'arg_options' => array(
'sanitize_callback' => array( $this, 'sanitize_username' ),
),
),
'name' => array(
'description' => __( 'Display name for the user.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
'first_name' => array(
'description' => __( 'First name for the user.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
'last_name' => array(
'description' => __( 'Last name for the user.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
'email' => array(
'description' => __( 'The email address for the user.', 'lifterlms' ),
'type' => 'string',
'format' => 'email',
'context' => array( 'edit' ),
'required' => true,
),
'url' => array(
'description' => __( 'URL of the user.', 'lifterlms' ),
'type' => 'string',
'format' => 'uri',
'context' => array( 'view', 'edit' ),
),
'description' => array(
'description' => __( 'Description of the user.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
),
'nickname' => array(
'description' => __( 'The nickname for the user.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
'registered_date' => array(
'description' => __( 'Registration date for the user.', 'lifterlms' ),
'type' => 'string',
'format' => 'date-time',
'context' => array( 'edit' ),
'readonly' => true,
),
'roles' => array(
'description' => __( 'Roles assigned to the user.', 'lifterlms' ),
'type' => 'array',
'items' => array(
'type' => 'string',
'enum' => $this->get_enum_roles(),
),
'context' => array( 'edit' ),
'default' => array( 'student' ),
),
'password' => array(
'description' => __( 'Password for the user (never included).', 'lifterlms' ),
'type' => 'string',
'context' => array(), // Password is never displayed.
'arg_options' => array(
'sanitize_callback' => array( $this, 'sanitize_password' ),
),
),
'billing_address_1' => array(
'description' => __( 'User address line 1.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
'billing_address_2' => array(
'description' => __( 'User address line 2.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
'billing_city' => array(
'description' => __( 'User address city name.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
'billing_state' => array(
'description' => __( 'User address ISO code for the state, province, or district.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
'billing_postcode' => array(
'description' => __( 'User address postal code.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
'billing_country' => array(
'description' => __( 'User address ISO code for the country.', 'lifterlms' ),
'type' => 'string',
'context' => array( 'edit' ),
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
),
);
if ( get_option( 'show_avatars' ) ) {
$avatar_properties = array();
foreach ( rest_get_avatar_sizes() as $size ) {
$avatar_properties[ $size ] = array(
// Translators: %d = avatar image size in pixels.
'description' => sprintf( __( 'Avatar URL with image size of %d pixels.', 'lifterlms' ), $size ),
'type' => 'string',
'format' => 'uri',
'context' => array( 'view', 'edit' ),
);
}
$schema['properties']['avatar_urls'] = array(
'description' => __( 'Avatar URLs for the user.', 'lifterlms' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
'properties' => $avatar_properties,
);
}
return $schema;
}
/**
* Retrieve a query object based on arguments from a `get_items()` (collection) request
*
* @since 1.0.0-beta.1
* @since 1.0.0-beta.12 Parse `search` and `search_columns` args.
*
* @param array $prepared Array of collection arguments.
* @param WP_REST_Request $request Request object.
* @return WP_User_Query
*/
protected function get_objects_query( $prepared, $request ) {
if ( 'id' === $prepared['orderby'] ) {
$prepared['orderby'] = 'ID';
} elseif ( 'registered_date' === $prepared['orderby'] ) {
$prepared['orderby'] = 'registered';
}
$args = array(
'paged' => $prepared['page'],
'number' => $prepared['per_page'],
'order' => strtoupper( $prepared['order'] ),
'orderby' => $prepared['orderby'],
);
if ( ! empty( $prepared['roles'] ) ) {
$args['role__in'] = $prepared['roles'];
}
if ( ! empty( $prepared['include'] ) ) {
$args['include'] = $prepared['include'];
}
if ( ! empty( $prepared['exclude'] ) ) {
$args['exclude'] = $prepared['exclude'];
}
if ( ! empty( $prepared['search'] ) ) {
$args['search'] = $prepared['search'];
}
if ( ! empty( $prepared['search_columns'] ) ) {
$args['search_columns'] = $prepared['search_columns'];
}
return new WP_User_Query( $args );
}
/**
* Retrieve an array of objects from the result of `$this->get_objects_query()`
*
* @since 1.0.0-beta.1
*
* @param obj $query Objects query result.
* @return WP_User[]
*/
protected function get_objects_from_query( $query ) {
return $query->get_results();
}
/**
* Retrieve pagination information from an objects query.
*
* @since 1.0.0-beta.1
*
* @param WP_User_Query $query Objects query result returned by {@see LLMS_REST_Users_Controller::get_objects_query()}.
* @param array $prepared Array of collection arguments.
* @param WP_REST_Request $request Request object.
* @return array {
* Array of pagination information.
*
* @type int $current_page Current page number.
* @type int $total_results Total number of results.
* @type int $total_pages Total number of results pages.
* }
*/
protected function get_pagination_data_from_query( $query, $prepared, $request ) {
$current_page = absint( $prepared['page'] );
$total_results = $query->get_total();
$total_pages = absint( ceil( $total_results / $prepared['per_page'] ) );
return compact( 'current_page', 'total_results', 'total_pages' );
}
/**
* Map request keys to database keys for insertion
*
* Array keys are the request fields (as defined in the schema) and
* array values are the database fields.
*
* @since 1.0.0-beta.1
* @since 1.0.0-beta.11 Correctly map request's `billing_postcode` param to `billing_zip` meta.
*
* @return array
*/
protected function map_schema_to_database() {
$map = parent::map_schema_to_database();
$map['username'] = 'user_login';
$map['password'] = 'user_pass';
$map['name'] = 'display_name';
$map['email'] = 'user_email';
$map['url'] = 'user_url';
$map['registered_date'] = 'user_registered';
$map['billing_postcode'] = 'billing_zip';
// Not inserted/read via database calls.
unset( $map['roles'], $map['avatar_urls'] );
return $map;
}
/**
* Prepare request arguments for a database insert/update
*
* @since 1.0.0-beta.1
*
* @param WP_Rest_Request $request Request object.
* @return array
*/
protected function prepare_item_for_database( $request ) {
$prepared = parent::prepare_item_for_database( $request );
// If we're creating a new item, maybe add some defaults.
if ( empty( $prepared['id'] ) ) {
// Pass an explicit false to `wp_insert_user()`.
$prepared['role'] = false;
if ( empty( $prepared['user_pass'] ) ) {
$prepared['user_pass'] = wp_generate_password( 22 );
}
if ( empty( $prepared['user_login'] ) ) {
$prepared['user_login'] = LLMS_Person_Handler::generate_username( $prepared['user_email'] );
}
}
return $prepared;
}
/**
* Prepare an object for response
*
* @since 1.0.0-beta.1
* @since 1.0.0-beta.14 Only add remapped keys to the response when the schema key is present in the expected response fields array.
*
* @param LLMS_Abstract_User_Data $object User object.
* @param WP_REST_Request $request Request object.
* @return array
*/
protected function prepare_object_for_response( $object, $request ) {
$prepared = array();
$map = array_flip( $this->map_schema_to_database() );
$fields = $this->get_fields_for_response( $request );
// Write Only.
unset( $map['user_pass'] );
foreach ( $map as $db_key => $schema_key ) {
if ( in_array( $schema_key, $fields, true ) ) {
$prepared[ $schema_key ] = $object->get( $db_key );
}
}
if ( in_array( 'roles', $fields, true ) ) {
$prepared['roles'] = $object->get_user()->roles;
}
if ( in_array( 'avatar_urls', $fields, true ) ) {
$prepared['avatar_urls'] = rest_get_avatar_urls( $object->get( 'user_email' ) );
}
return $prepared;
}
/**
* Validate a username is valid and allowed
*
* @since 1.0.0-beta.1
*
* @param string $value User-submitted username.
* @param WP_REST_Request $request Request object.
* @param string $param Parameter name.
* @return WP_Error|string Sanitized username if valid or error object.
*/
public function sanitize_password( $value, $request, $param ) {
$password = (string) $value;
if ( false !== strpos( $password, '\\' ) ) {
return llms_rest_bad_request_error( __( 'Passwords cannot contain the "\\" character.', 'lifterlms' ) );
}
// @todo: Should validate against password strength too, maybe?
return $password;
}
/**
* Validate a username is valid and allowed
*
* @since 1.0.0-beta.1
*
* @param string $value User-submitted username.
* @param WP_REST_Request $request Request object.
* @param string $param Parameter name.
* @return WP_Error|string Sanitized username if valid or error object.
*/
public function sanitize_username( $value, $request, $param ) {
$username = (string) $value;
if ( ! validate_username( $username ) ) {
return llms_rest_bad_request_error( __( 'Username contains invalid characters.', 'lifterlms' ) );
}
/**
* Filter defined in WP Core.
*
* @link https://developer.wordpress.org/reference/hooks/illegal_user_logins/
*
* @param array $illegal_logins Array of banned usernames.
*/
$illegal_logins = (array) apply_filters( 'illegal_user_logins', array() );
if ( in_array( strtolower( $username ), array_map( 'strtolower', $illegal_logins ), true ) ) {
return llms_rest_bad_request_error( __( 'Username is not allowed.', 'lifterlms' ) );
}
return $username;
}
/**
* Updates additional information not handled by WP Core insert/update user functions
*
* @since 1.0.0-beta.1
* @since 1.0.0-beta.10 Fixed setting roles instead of appending them.
* @since 1.0.0-beta.11 Made sure to set user's meta with the correct db key.
*
* @param int $object_id WP User id.
* @param array $prepared Prepared item data.
* @param WP_REST_Request $request Request object.
* @return LLMS_Abstract_User_Data|WP_error
*/
protected function update_additional_data( $object_id, $prepared, $request ) {
$object = $this->get_object( $object_id );
if ( is_wp_error( $object ) ) {
return $object;
}
$metas = array(
'billing_address_1',
'billing_address_2',
'billing_city',
'billing_state',
'billing_postcode',
'billing_country',
);
$map = $this->map_schema_to_database();
foreach ( $metas as $meta ) {
if ( ! empty( $map[ $meta ] ) && ! empty( $prepared[ $map[ $meta ] ] ) ) {
$object->set( $map[ $meta ], $prepared[ $map[ $meta ] ] );
}
}
if ( ! empty( $request['roles'] ) ) {
$user = $object->get_user();
$user->set_role( '' );
foreach ( $request['roles'] as $role ) {
$user->add_role( $role );
}
}
return $object;
}
/**
* Update item
*
* @since 1.0.0-beta.1
*
* @param WP_REST_Request $request Request object.
* @return WP_REST_Response|WP_Error Response object or `WP_Error` on failure.
*/
public function update_item( $request ) {
$object = $this->get_object( $request['id'] );
if ( is_wp_error( $object ) ) {
return $object;
}
// Ensure we're not trying to update the email to an email that already exists.
$owner_id = email_exists( $request['email'] );
if ( $owner_id && $owner_id !== $request['id'] ) {
return llms_rest_bad_request_error( __( 'Invalid email address.', 'lifterlms' ) );
}
// Cannot change a username.
if ( ! empty( $request['username'] ) && $request['username'] !== $object->get( 'user_login' ) ) {
return llms_rest_bad_request_error( __( 'Username is not editable.', 'lifterlms' ) );
}
return parent::update_item( $request );
}
/**
* Update the object in the database with prepared data
*
* @since 1.0.0-beta.1
*
* @param array $prepared Prepared item data.
* @param WP_REST_Request $request Request object.
* @return obj Object Instance of object from `$this->get_object()`.
*/
protected function update_object( $prepared, $request ) {
$prepared['ID'] = $prepared['id'];
$object_id = wp_update_user( $prepared );
if ( is_wp_error( $object_id ) ) {
return $object_id;
}
unset( $prepared['ID'] );
return $this->update_additional_data( $object_id, $prepared, $request );
}
}