PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.0
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.0
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / users-window / rest.php

rest.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.0, at includes/users-window/rest.php

637 lines 18.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — Native Users Window: REST mutation routes.
4 *
5 * Three endpoints under `desktop-mode/v1`:
6 *
7 * - POST /users/bulk-role { ids: int[], role: string }
8 * - POST /users/<id>/send-password-reset
9 * - POST /users/<id>/resend-welcome
10 * - POST /users/bulk-delete { ids: int[], reassign?: int }
11 *
12 * SECURITY POSTURE
13 * ================
14 *
15 * Every route does TWO checks:
16 *
17 * 1. `permission_callback` — the broad cap gate (`promote_users`,
18 * `edit_users`, `delete_users` / `remove_users`). Stops a
19 * non-admin from even reaching the callback.
20 *
21 * 2. Per-target re-validation inside the callback:
22 * - bulk-role re-derives `get_editable_roles()` and rejects
23 * any role outside it. This is what stops an Editor from
24 * forging a request that promotes someone to Administrator.
25 * - bulk-delete checks `current_user_can( 'delete_user', $id )`
26 * per row. Multisite uses `remove_user_from_blog` instead.
27 * - mutation routes refuse self-targeting on operations that
28 * could lock the requester out (demote-self-from-admin,
29 * delete-self).
30 *
31 * @package WPDesktopMode
32 * @since 0.18.0
33 */
34
35 defined( 'ABSPATH' ) || exit;
36
37 /**
38 * Register the four routes.
39 *
40 * @since 0.18.0
41 */
42 function desktop_mode_users_window_register_rest_routes() {
43 register_rest_route(
44 'desktop-mode/v1',
45 '/users/bulk-role',
46 array(
47 'methods' => WP_REST_Server::CREATABLE,
48 'callback' => 'desktop_mode_users_window_rest_bulk_role',
49 'permission_callback' => static function () {
50 return current_user_can( 'promote_users' );
51 },
52 'args' => array(
53 'ids' => array(
54 'required' => true,
55 'type' => 'array',
56 'items' => array( 'type' => 'integer' ),
57 ),
58 'role' => array(
59 'required' => true,
60 'type' => 'string',
61 ),
62 ),
63 )
64 );
65
66 register_rest_route(
67 'desktop-mode/v1',
68 '/users/(?P<id>\d+)/send-password-reset',
69 array(
70 'methods' => WP_REST_Server::CREATABLE,
71 'callback' => 'desktop_mode_users_window_rest_send_password_reset',
72 'permission_callback' => static function () {
73 return current_user_can( 'edit_users' );
74 },
75 'args' => array(
76 'id' => array(
77 'required' => true,
78 'type' => 'integer',
79 ),
80 ),
81 )
82 );
83
84 register_rest_route(
85 'desktop-mode/v1',
86 '/users/(?P<id>\d+)/resend-welcome',
87 array(
88 'methods' => WP_REST_Server::CREATABLE,
89 'callback' => 'desktop_mode_users_window_rest_resend_welcome',
90 'permission_callback' => static function () {
91 return current_user_can( 'edit_users' );
92 },
93 'args' => array(
94 'id' => array(
95 'required' => true,
96 'type' => 'integer',
97 ),
98 ),
99 )
100 );
101
102 register_rest_route(
103 'desktop-mode/v1',
104 '/users',
105 array(
106 'methods' => WP_REST_Server::CREATABLE,
107 'callback' => 'desktop_mode_users_window_rest_create',
108 'permission_callback' => static function () {
109 return current_user_can( 'create_users' );
110 },
111 'args' => array(
112 'username' => array(
113 'required' => true,
114 'type' => 'string',
115 ),
116 'email' => array(
117 'required' => true,
118 'type' => 'string',
119 ),
120 'first_name' => array( 'type' => 'string' ),
121 'last_name' => array( 'type' => 'string' ),
122 'url' => array( 'type' => 'string' ),
123 'locale' => array( 'type' => 'string' ),
124 'password' => array( 'type' => 'string' ),
125 'role' => array( 'type' => 'string' ),
126 'send_notification' => array( 'type' => 'boolean' ),
127 ),
128 )
129 );
130
131 register_rest_route(
132 'desktop-mode/v1',
133 '/users/bulk-delete',
134 array(
135 'methods' => WP_REST_Server::CREATABLE,
136 'callback' => 'desktop_mode_users_window_rest_bulk_delete',
137 'permission_callback' => static function () {
138 return is_multisite()
139 ? current_user_can( 'remove_users' )
140 : current_user_can( 'delete_users' );
141 },
142 'args' => array(
143 'ids' => array(
144 'required' => true,
145 'type' => 'array',
146 'items' => array( 'type' => 'integer' ),
147 ),
148 'reassign' => array(
149 'required' => false,
150 'type' => 'integer',
151 ),
152 ),
153 )
154 );
155 }
156 add_action( 'rest_api_init', 'desktop_mode_users_window_register_rest_routes' );
157
158 /**
159 * `POST /users/bulk-role`
160 *
161 * Body: `{ ids: int[], role: string }`. Returns a per-id result map:
162 * `{ <id>: { ok: bool, error?: string } }`. Partial success is the
163 * norm — a request to promote five users where the requester can
164 * edit four of them succeeds for those four and reports `forbidden`
165 * for the fifth.
166 *
167 * @since 0.18.0
168 *
169 * @param WP_REST_Request $req
170 * @return WP_REST_Response|WP_Error
171 */
172 function desktop_mode_users_window_rest_bulk_role( $req ) {
173 $ids = array_values(
174 array_filter(
175 array_map( 'intval', (array) $req->get_param( 'ids' ) ),
176 static function ( $id ) {
177 return $id > 0;
178 }
179 )
180 );
181 $role = sanitize_key( (string) $req->get_param( 'role' ) );
182
183 if ( empty( $ids ) ) {
184 return new WP_Error(
185 'desktop_mode_users_no_ids',
186 __( 'No user ids supplied.', 'desktop-mode' ),
187 array( 'status' => 400 )
188 );
189 }
190
191 // Cap to a sane upper bound so a runaway client can't flood
192 // `wp_update_user` calls in one request.
193 $ids = array_slice( $ids, 0, 100 );
194
195 $viewer_id = (int) get_current_user_id();
196 $assignable = desktop_mode_users_window_assignable_roles( $viewer_id );
197 if ( ! in_array( $role, $assignable, true ) ) {
198 return new WP_Error(
199 'desktop_mode_users_role_forbidden',
200 __( 'You are not allowed to assign this role.', 'desktop-mode' ),
201 array( 'status' => 403 )
202 );
203 }
204
205 $results = array();
206 foreach ( $ids as $id ) {
207 $id = (int) $id;
208 // Per-target permission. `edit_user` already encapsulates the
209 // "can the viewer manage this specific user?" check.
210 if ( ! current_user_can( 'edit_user', $id ) ) {
211 $results[ (string) $id ] = array(
212 'ok' => false,
213 'error' => 'forbidden',
214 );
215 continue;
216 }
217
218 // Self-demotion guard: don't let the requester strip their
219 // own admin role and lock themselves out. Match WP core's
220 // behaviour in the classic users.php flow.
221 if ( $id === $viewer_id ) {
222 $existing = (array) ( get_userdata( $id )->roles ?? array() );
223 $is_admin = in_array( 'administrator', $existing, true );
224 if ( $is_admin && 'administrator' !== $role ) {
225 $results[ (string) $id ] = array(
226 'ok' => false,
227 'error' => 'self_demote',
228 );
229 continue;
230 }
231 }
232
233 $user = get_userdata( $id );
234 if ( ! $user instanceof WP_User ) {
235 $results[ (string) $id ] = array(
236 'ok' => false,
237 'error' => 'not_found',
238 );
239 continue;
240 }
241
242 // `set_role` replaces all roles with the single new one —
243 // matches the classic users.php "Change role to…" semantics.
244 $user->set_role( $role );
245
246 $results[ (string) $id ] = array( 'ok' => true );
247 }
248
249 return rest_ensure_response(
250 array(
251 'role' => $role,
252 'results' => $results,
253 )
254 );
255 }
256
257 /**
258 * `POST /users/<id>/send-password-reset`
259 *
260 * Triggers WP's standard password-reset email flow. We delegate to
261 * core's `retrieve_password()` so the email format stays consistent
262 * with the login screen's "Lost your password?" link.
263 *
264 * @since 0.18.0
265 *
266 * @param WP_REST_Request $req
267 * @return WP_REST_Response|WP_Error
268 */
269 function desktop_mode_users_window_rest_send_password_reset( $req ) {
270 $id = (int) $req->get_param( 'id' );
271 $user = $id > 0 ? get_userdata( $id ) : null;
272 if ( ! $user instanceof WP_User ) {
273 return new WP_Error(
274 'desktop_mode_users_not_found',
275 __( 'User not found.', 'desktop-mode' ),
276 array( 'status' => 404 )
277 );
278 }
279 if ( ! current_user_can( 'edit_user', $id ) ) {
280 return new WP_Error(
281 'desktop_mode_users_forbidden',
282 __( 'You are not allowed to send a password reset for this user.', 'desktop-mode' ),
283 array( 'status' => 403 )
284 );
285 }
286
287 // Lightweight throttle: at most one reset email per (requester,
288 // target) pair per minute. Stops accidental double-clicks from
289 // firing two emails AND closes a small abuse vector where an
290 // admin bot account could spam reset emails to a victim.
291 $throttle_key = sprintf(
292 '_dm_pw_reset_throttle_%d_%d',
293 (int) get_current_user_id(),
294 $id
295 );
296 $last = (int) get_transient( $throttle_key );
297 if ( $last > 0 && ( time() - $last ) < 60 ) {
298 return new WP_Error(
299 'desktop_mode_users_throttled',
300 __( 'A reset email was already sent recently. Try again in a minute.', 'desktop-mode' ),
301 array( 'status' => 429 )
302 );
303 }
304 set_transient( $throttle_key, time(), MINUTE_IN_SECONDS );
305
306 // `retrieve_password( $login )` returns true on success or
307 // WP_Error on mailer/db failure. It also fires the standard
308 // `retrieve_password` action so plugins (audit logs, 2FA flows)
309 // see this as a normal reset-request event.
310 $result = retrieve_password( $user->user_login );
311 if ( is_wp_error( $result ) ) {
312 return $result;
313 }
314
315 return rest_ensure_response(
316 array(
317 'ok' => true,
318 'email' => $user->user_email,
319 )
320 );
321 }
322
323 /**
324 * `POST /users/<id>/resend-welcome`
325 *
326 * Re-sends the new-user notification email. Useful for users who
327 * never opened the original (filtered to spam, typo'd address that's
328 * since been corrected, …).
329 *
330 * @since 0.18.0
331 *
332 * @param WP_REST_Request $req
333 * @return WP_REST_Response|WP_Error
334 */
335 function desktop_mode_users_window_rest_resend_welcome( $req ) {
336 $id = (int) $req->get_param( 'id' );
337 $user = $id > 0 ? get_userdata( $id ) : null;
338 if ( ! $user instanceof WP_User ) {
339 return new WP_Error(
340 'desktop_mode_users_not_found',
341 __( 'User not found.', 'desktop-mode' ),
342 array( 'status' => 404 )
343 );
344 }
345 if ( ! current_user_can( 'edit_user', $id ) ) {
346 return new WP_Error(
347 'desktop_mode_users_forbidden',
348 __( 'You are not allowed to email this user.', 'desktop-mode' ),
349 array( 'status' => 403 )
350 );
351 }
352
353 // Same throttle as the password-reset route — stops repeated
354 // "Resend" clicks from spamming the mailer.
355 $throttle_key = sprintf(
356 '_dm_welcome_throttle_%d_%d',
357 (int) get_current_user_id(),
358 $id
359 );
360 $last = (int) get_transient( $throttle_key );
361 if ( $last > 0 && ( time() - $last ) < 60 ) {
362 return new WP_Error(
363 'desktop_mode_users_throttled',
364 __( 'A welcome email was already sent recently. Try again in a minute.', 'desktop-mode' ),
365 array( 'status' => 429 )
366 );
367 }
368 set_transient( $throttle_key, time(), MINUTE_IN_SECONDS );
369
370 // Notify only the user; pass an empty password placeholder so
371 // core sends the user-facing welcome variant. The user keeps
372 // their existing credentials — this resends the WELCOME email,
373 // not a password.
374 wp_new_user_notification( $id, null, 'user' );
375
376 return rest_ensure_response(
377 array(
378 'ok' => true,
379 'email' => $user->user_email,
380 )
381 );
382 }
383
384 /**
385 * `POST /users/bulk-delete`
386 *
387 * Single-site: hard-deletes the user account, optionally
388 * reassigning their content to `reassign`.
389 * Multisite: removes the user from the current site (network user
390 * record stays). Per-target re-validation either way.
391 *
392 * @since 0.18.0
393 *
394 * @param WP_REST_Request $req
395 * @return WP_REST_Response|WP_Error
396 */
397 function desktop_mode_users_window_rest_bulk_delete( $req ) {
398 $ids = array_values(
399 array_filter(
400 array_map( 'intval', (array) $req->get_param( 'ids' ) ),
401 static function ( $id ) {
402 return $id > 0;
403 }
404 )
405 );
406 $reassign = (int) $req->get_param( 'reassign' );
407 $viewer_id = (int) get_current_user_id();
408
409 if ( empty( $ids ) ) {
410 return new WP_Error(
411 'desktop_mode_users_no_ids',
412 __( 'No user ids supplied.', 'desktop-mode' ),
413 array( 'status' => 400 )
414 );
415 }
416 $ids = array_slice( $ids, 0, 100 );
417
418 if ( ! function_exists( 'wp_delete_user' ) ) {
419 require_once ABSPATH . 'wp-admin/includes/user.php';
420 }
421
422 $results = array();
423 foreach ( $ids as $id ) {
424 $id = (int) $id;
425
426 // Self-delete guard — same posture as core's classic users.php.
427 if ( $id === $viewer_id ) {
428 $results[ (string) $id ] = array(
429 'ok' => false,
430 'error' => 'self_delete',
431 );
432 continue;
433 }
434
435 if ( is_multisite() ) {
436 if ( ! current_user_can( 'remove_user', $id ) ) {
437 $results[ (string) $id ] = array(
438 'ok' => false,
439 'error' => 'forbidden',
440 );
441 continue;
442 }
443 $ok = remove_user_from_blog( $id, get_current_blog_id(), $reassign > 0 ? $reassign : null );
444 $results[ (string) $id ] = $ok && ! is_wp_error( $ok )
445 ? array( 'ok' => true )
446 : array(
447 'ok' => false,
448 'error' => 'remove_failed',
449 );
450 continue;
451 }
452
453 // Single-site path.
454 if ( ! current_user_can( 'delete_user', $id ) ) {
455 $results[ (string) $id ] = array(
456 'ok' => false,
457 'error' => 'forbidden',
458 );
459 continue;
460 }
461 $ok = wp_delete_user( $id, $reassign > 0 ? $reassign : null );
462 $results[ (string) $id ] = $ok
463 ? array( 'ok' => true )
464 : array(
465 'ok' => false,
466 'error' => 'delete_failed',
467 );
468 }
469
470 return rest_ensure_response(
471 array(
472 'results' => $results,
473 )
474 );
475 }
476
477 /**
478 * `POST /users` — create a new WordPress user.
479 *
480 * Mirrors the field set core gathers in `wp-admin/user-new.php`:
481 * username (required), email (required), first/last name, website,
482 * locale, password (auto-generated when omitted), role, and a
483 * "send notification email" toggle.
484 *
485 * Capability gate: `create_users`. Per-target gates in addition:
486 *
487 * - role (if supplied) must be in the requester's
488 * `editable_roles()` map. An Editor can't create an
489 * Administrator even with `create_users` granted.
490 * - the user must not already exist by username OR email.
491 * - inputs are sanitized through core's `sanitize_user`,
492 * `sanitize_email`, `esc_url_raw`, `sanitize_text_field`.
493 *
494 * On success returns `{ ok: true, user_id: int, email: string }`.
495 * On failure returns the matching `WP_Error` (404/400/403/409
496 * depending on cause).
497 *
498 * @since 0.18.0
499 *
500 * @param WP_REST_Request $req
501 * @return WP_REST_Response|WP_Error
502 */
503 function desktop_mode_users_window_rest_create( $req ) {
504 $username = sanitize_user( (string) $req->get_param( 'username' ), true );
505 $email = sanitize_email( (string) $req->get_param( 'email' ) );
506 $first = sanitize_text_field( (string) $req->get_param( 'first_name' ) );
507 $last = sanitize_text_field( (string) $req->get_param( 'last_name' ) );
508 $url = esc_url_raw( (string) $req->get_param( 'url' ) );
509 $locale = (string) $req->get_param( 'locale' );
510 $password = (string) $req->get_param( 'password' );
511 $role = sanitize_key( (string) $req->get_param( 'role' ) );
512 $notify = (bool) $req->get_param( 'send_notification' );
513
514 if ( '' === $username ) {
515 return new WP_Error(
516 'desktop_mode_users_username_required',
517 __( 'Username is required.', 'desktop-mode' ),
518 array( 'status' => 400 )
519 );
520 }
521 if ( ! validate_username( $username ) ) {
522 return new WP_Error(
523 'desktop_mode_users_username_invalid',
524 __( 'Username is not valid.', 'desktop-mode' ),
525 array( 'status' => 400 )
526 );
527 }
528 if ( '' === $email || ! is_email( $email ) ) {
529 return new WP_Error(
530 'desktop_mode_users_email_invalid',
531 __( 'A valid email address is required.', 'desktop-mode' ),
532 array( 'status' => 400 )
533 );
534 }
535 if ( username_exists( $username ) ) {
536 return new WP_Error(
537 'desktop_mode_users_username_exists',
538 __( 'That username is already in use.', 'desktop-mode' ),
539 array( 'status' => 409 )
540 );
541 }
542 if ( email_exists( $email ) ) {
543 return new WP_Error(
544 'desktop_mode_users_email_exists',
545 __( 'That email is already in use.', 'desktop-mode' ),
546 array( 'status' => 409 )
547 );
548 }
549
550 // Role gate. Empty role → fall back to the site default. A
551 // non-empty role MUST be in `editable_roles()` for the requester
552 // — same protection as the bulk-role endpoint, applied at create
553 // time so an Editor can't create an Administrator.
554 if ( '' === $role ) {
555 $role = (string) get_option( 'default_role', 'subscriber' );
556 }
557 $assignable = desktop_mode_users_window_assignable_roles( (int) get_current_user_id() );
558 // `desktop_mode_users_window_assignable_roles` is gated on
559 // `promote_users` — viewers with `create_users` but not
560 // `promote_users` need a fallback. Allow them to assign the
561 // default role only.
562 if ( empty( $assignable ) ) {
563 $assignable = array( (string) get_option( 'default_role', 'subscriber' ) );
564 }
565 if ( ! in_array( $role, $assignable, true ) ) {
566 return new WP_Error(
567 'desktop_mode_users_role_forbidden',
568 __( 'You are not allowed to assign that role.', 'desktop-mode' ),
569 array( 'status' => 403 )
570 );
571 }
572
573 // Auto-generate a password when none supplied; matches core's
574 // classic behaviour. The user can complete the password reset
575 // via the email notification.
576 if ( '' === $password ) {
577 $password = wp_generate_password( 24, true, true );
578 }
579
580 $userdata = array(
581 'user_login' => $username,
582 'user_email' => $email,
583 'user_pass' => $password,
584 'first_name' => $first,
585 'last_name' => $last,
586 'user_url' => $url,
587 'role' => $role,
588 );
589
590 $user_id = wp_insert_user( $userdata );
591 if ( is_wp_error( $user_id ) ) {
592 // Keep core's error code so the JS can map common cases
593 // (`existing_user_login`, `existing_user_email`) to
594 // localized messages.
595 return $user_id;
596 }
597
598 // Locale (post-create — `wp_insert_user` doesn't take it).
599 if ( '' !== $locale ) {
600 $locale_slugs = array_keys( desktop_mode_users_window_locales_map() );
601 if ( in_array( $locale, $locale_slugs, true ) ) {
602 update_user_meta( (int) $user_id, 'locale', $locale );
603 }
604 }
605
606 if ( $notify ) {
607 // `'both'` — admin + user. Same flag classic users.php sets
608 // when "Send the new user an email about their account" is
609 // checked.
610 wp_new_user_notification( (int) $user_id, null, 'both' );
611 }
612
613 /**
614 * Fires after the Users window has created a new account.
615 *
616 * @since 0.18.0
617 *
618 * @param int $user_id
619 * @param WP_User $user Wrapped user object.
620 * @param array $args Sanitized args used for creation.
621 */
622 do_action(
623 'desktop_mode_users_window_user_created',
624 (int) $user_id,
625 get_userdata( (int) $user_id ),
626 $userdata
627 );
628
629 return rest_ensure_response(
630 array(
631 'ok' => true,
632 'user_id' => (int) $user_id,
633 'email' => $email,
634 )
635 );
636 }
637