PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
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.7, at includes/users-window/rest.php

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