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

627 lines 18.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — 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 `openstation_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 OpenStation
37 */
38
39 defined( 'ABSPATH' ) || exit;
40
41 /**
42 * Register the five routes.
43 */
44 function openstation_users_window_register_rest_routes() {
45 register_rest_route(
46 'desktop-mode/v1',
47 '/users/bulk-role',
48 array(
49 'methods' => WP_REST_Server::CREATABLE,
50 'callback' => 'openstation_users_window_rest_bulk_role',
51 'permission_callback' => static function () {
52 return current_user_can( 'promote_users' );
53 },
54 'args' => array(
55 'ids' => array(
56 'required' => true,
57 'type' => 'array',
58 'items' => array( 'type' => 'integer' ),
59 ),
60 'role' => array(
61 'required' => true,
62 'type' => 'string',
63 ),
64 ),
65 )
66 );
67
68 register_rest_route(
69 'desktop-mode/v1',
70 '/users/(?P<id>\d+)/send-password-reset',
71 array(
72 'methods' => WP_REST_Server::CREATABLE,
73 'callback' => 'openstation_users_window_rest_send_password_reset',
74 'permission_callback' => static function () {
75 return current_user_can( 'edit_users' );
76 },
77 'args' => array(
78 'id' => array(
79 'required' => true,
80 'type' => 'integer',
81 ),
82 ),
83 )
84 );
85
86 register_rest_route(
87 'desktop-mode/v1',
88 '/users/(?P<id>\d+)/resend-welcome',
89 array(
90 'methods' => WP_REST_Server::CREATABLE,
91 'callback' => 'openstation_users_window_rest_resend_welcome',
92 'permission_callback' => static function () {
93 return current_user_can( 'edit_users' );
94 },
95 'args' => array(
96 'id' => array(
97 'required' => true,
98 'type' => 'integer',
99 ),
100 ),
101 )
102 );
103
104 register_rest_route(
105 'desktop-mode/v1',
106 '/users',
107 array(
108 'methods' => WP_REST_Server::CREATABLE,
109 'callback' => 'openstation_users_window_rest_create',
110 'permission_callback' => static function () {
111 return current_user_can( 'create_users' );
112 },
113 'args' => array(
114 'username' => array(
115 'required' => true,
116 'type' => 'string',
117 ),
118 'email' => array(
119 'required' => true,
120 'type' => 'string',
121 ),
122 'first_name' => array( 'type' => 'string' ),
123 'last_name' => array( 'type' => 'string' ),
124 'url' => array( 'type' => 'string' ),
125 'locale' => array( 'type' => 'string' ),
126 'password' => array( 'type' => 'string' ),
127 'role' => array( 'type' => 'string' ),
128 'send_notification' => array( 'type' => 'boolean' ),
129 ),
130 )
131 );
132
133 register_rest_route(
134 'desktop-mode/v1',
135 '/users/bulk-delete',
136 array(
137 'methods' => WP_REST_Server::CREATABLE,
138 'callback' => 'openstation_users_window_rest_bulk_delete',
139 'permission_callback' => static function () {
140 return is_multisite()
141 ? current_user_can( 'remove_users' )
142 : current_user_can( 'delete_users' );
143 },
144 'args' => array(
145 'ids' => array(
146 'required' => true,
147 'type' => 'array',
148 'items' => array( 'type' => 'integer' ),
149 ),
150 'reassign' => array(
151 'required' => false,
152 'type' => 'integer',
153 ),
154 ),
155 )
156 );
157 }
158 add_action( 'rest_api_init', 'openstation_users_window_register_rest_routes' );
159
160 /**
161 * `POST /users/bulk-role`
162 *
163 * Body: `{ ids: int[], role: string }`. Returns a per-id result map:
164 * `{ <id>: { ok: bool, error?: string } }`. Partial success is the
165 * norm — a request to promote five users where the requester can
166 * edit four of them succeeds for those four and reports `forbidden`
167 * for the fifth.
168 *
169 * @param WP_REST_Request $req
170 * @return WP_REST_Response|WP_Error
171 */
172 function openstation_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 'openstation_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 = openstation_users_window_assignable_roles( $viewer_id );
197 if ( ! in_array( $role, $assignable, true ) ) {
198 return new WP_Error(
199 'openstation_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 * @param WP_REST_Request $req
265 * @return WP_REST_Response|WP_Error
266 */
267 function openstation_users_window_rest_send_password_reset( $req ) {
268 $id = (int) $req->get_param( 'id' );
269 $user = $id > 0 ? get_userdata( $id ) : null;
270 if ( ! $user instanceof WP_User ) {
271 return new WP_Error(
272 'openstation_users_not_found',
273 __( 'User not found.', 'desktop-mode' ),
274 array( 'status' => 404 )
275 );
276 }
277 if ( ! current_user_can( 'edit_user', $id ) ) {
278 return new WP_Error(
279 'openstation_users_forbidden',
280 __( 'You are not allowed to send a password reset for this user.', 'desktop-mode' ),
281 array( 'status' => 403 )
282 );
283 }
284
285 // Lightweight throttle: at most one reset email per (requester,
286 // target) pair per minute. Stops accidental double-clicks from
287 // firing two emails AND closes a small abuse vector where an
288 // admin bot account could spam reset emails to a victim.
289 $throttle_key = sprintf(
290 '_dm_pw_reset_throttle_%d_%d',
291 (int) get_current_user_id(),
292 $id
293 );
294 $last = (int) get_transient( $throttle_key );
295 if ( $last > 0 && ( time() - $last ) < 60 ) {
296 return new WP_Error(
297 'openstation_users_throttled',
298 __( 'A reset email was already sent recently. Try again in a minute.', 'desktop-mode' ),
299 array( 'status' => 429 )
300 );
301 }
302 set_transient( $throttle_key, time(), MINUTE_IN_SECONDS );
303
304 // `retrieve_password( $login )` returns true on success or
305 // WP_Error on mailer/db failure. It also fires the standard
306 // `retrieve_password` action so plugins (audit logs, 2FA flows)
307 // see this as a normal reset-request event.
308 $result = retrieve_password( $user->user_login );
309 if ( is_wp_error( $result ) ) {
310 return $result;
311 }
312
313 return rest_ensure_response(
314 array(
315 'ok' => true,
316 'email' => $user->user_email,
317 )
318 );
319 }
320
321 /**
322 * `POST /users/<id>/resend-welcome`
323 *
324 * Re-sends the new-user notification email. Useful for users who
325 * never opened the original (filtered to spam, typo'd address that's
326 * since been corrected, …).
327 *
328 * @param WP_REST_Request $req
329 * @return WP_REST_Response|WP_Error
330 */
331 function openstation_users_window_rest_resend_welcome( $req ) {
332 $id = (int) $req->get_param( 'id' );
333 $user = $id > 0 ? get_userdata( $id ) : null;
334 if ( ! $user instanceof WP_User ) {
335 return new WP_Error(
336 'openstation_users_not_found',
337 __( 'User not found.', 'desktop-mode' ),
338 array( 'status' => 404 )
339 );
340 }
341 if ( ! current_user_can( 'edit_user', $id ) ) {
342 return new WP_Error(
343 'openstation_users_forbidden',
344 __( 'You are not allowed to email this user.', 'desktop-mode' ),
345 array( 'status' => 403 )
346 );
347 }
348
349 // Same throttle as the password-reset route — stops repeated
350 // "Resend" clicks from spamming the mailer.
351 $throttle_key = sprintf(
352 '_dm_welcome_throttle_%d_%d',
353 (int) get_current_user_id(),
354 $id
355 );
356 $last = (int) get_transient( $throttle_key );
357 if ( $last > 0 && ( time() - $last ) < 60 ) {
358 return new WP_Error(
359 'openstation_users_throttled',
360 __( 'A welcome email was already sent recently. Try again in a minute.', 'desktop-mode' ),
361 array( 'status' => 429 )
362 );
363 }
364 set_transient( $throttle_key, time(), MINUTE_IN_SECONDS );
365
366 // Notify only the user; pass an empty password placeholder so
367 // core sends the user-facing welcome variant. The user keeps
368 // their existing credentials — this resends the WELCOME email,
369 // not a password.
370 wp_new_user_notification( $id, null, 'user' );
371
372 return rest_ensure_response(
373 array(
374 'ok' => true,
375 'email' => $user->user_email,
376 )
377 );
378 }
379
380 /**
381 * `POST /users/bulk-delete`
382 *
383 * Single-site: hard-deletes the user account, optionally
384 * reassigning their content to `reassign`.
385 * Multisite: removes the user from the current site (network user
386 * record stays). Per-target re-validation either way.
387 *
388 * @param WP_REST_Request $req
389 * @return WP_REST_Response|WP_Error
390 */
391 function openstation_users_window_rest_bulk_delete( $req ) {
392 $ids = array_values(
393 array_filter(
394 array_map( 'intval', (array) $req->get_param( 'ids' ) ),
395 static function ( $id ) {
396 return $id > 0;
397 }
398 )
399 );
400 $reassign = (int) $req->get_param( 'reassign' );
401 $viewer_id = (int) get_current_user_id();
402
403 if ( empty( $ids ) ) {
404 return new WP_Error(
405 'openstation_users_no_ids',
406 __( 'No user ids supplied.', 'desktop-mode' ),
407 array( 'status' => 400 )
408 );
409 }
410 $ids = array_slice( $ids, 0, 100 );
411
412 if ( ! function_exists( 'wp_delete_user' ) ) {
413 require_once ABSPATH . 'wp-admin/includes/user.php';
414 }
415
416 $results = array();
417 foreach ( $ids as $id ) {
418 $id = (int) $id;
419
420 // Self-delete guard — same posture as core's classic users.php.
421 if ( $id === $viewer_id ) {
422 $results[ (string) $id ] = array(
423 'ok' => false,
424 'error' => 'self_delete',
425 );
426 continue;
427 }
428
429 if ( is_multisite() ) {
430 if ( ! current_user_can( 'remove_user', $id ) ) {
431 $results[ (string) $id ] = array(
432 'ok' => false,
433 'error' => 'forbidden',
434 );
435 continue;
436 }
437 $ok = remove_user_from_blog( $id, get_current_blog_id(), $reassign > 0 ? $reassign : null );
438 $results[ (string) $id ] = $ok && ! is_wp_error( $ok )
439 ? array( 'ok' => true )
440 : array(
441 'ok' => false,
442 'error' => 'remove_failed',
443 );
444 continue;
445 }
446
447 // Single-site path.
448 if ( ! current_user_can( 'delete_user', $id ) ) {
449 $results[ (string) $id ] = array(
450 'ok' => false,
451 'error' => 'forbidden',
452 );
453 continue;
454 }
455 $ok = wp_delete_user( $id, $reassign > 0 ? $reassign : null );
456 $results[ (string) $id ] = $ok
457 ? array( 'ok' => true )
458 : array(
459 'ok' => false,
460 'error' => 'delete_failed',
461 );
462 }
463
464 return rest_ensure_response(
465 array(
466 'results' => $results,
467 )
468 );
469 }
470
471 /**
472 * `POST /users` — create a new WordPress user.
473 *
474 * Mirrors the field set core gathers in `wp-admin/user-new.php`:
475 * username (required), email (required), first/last name, website,
476 * locale, password (auto-generated when omitted), role, and a
477 * "send notification email" toggle.
478 *
479 * Capability gate: `create_users`. Per-target gates in addition:
480 *
481 * - role (if supplied) must be in the requester's
482 * `editable_roles()` map. An Editor can't create an
483 * Administrator even with `create_users` granted.
484 * - the user must not already exist by username OR email.
485 * - inputs are sanitized through core's `sanitize_user`,
486 * `sanitize_email`, `esc_url_raw`, `sanitize_text_field`.
487 *
488 * On success returns `{ ok: true, user_id: int, email: string }`.
489 * On failure returns the matching `WP_Error` (404/400/403/409
490 * depending on cause).
491 *
492 * @param WP_REST_Request $req
493 * @return WP_REST_Response|WP_Error
494 */
495 function openstation_users_window_rest_create( $req ) {
496 $username = sanitize_user( (string) $req->get_param( 'username' ), true );
497 $email = sanitize_email( (string) $req->get_param( 'email' ) );
498 $first = sanitize_text_field( (string) $req->get_param( 'first_name' ) );
499 $last = sanitize_text_field( (string) $req->get_param( 'last_name' ) );
500 $url = esc_url_raw( (string) $req->get_param( 'url' ) );
501 $locale = (string) $req->get_param( 'locale' );
502 $password = (string) $req->get_param( 'password' );
503 $role = sanitize_key( (string) $req->get_param( 'role' ) );
504 $notify = (bool) $req->get_param( 'send_notification' );
505
506 if ( '' === $username ) {
507 return new WP_Error(
508 'openstation_users_username_required',
509 __( 'Username is required.', 'desktop-mode' ),
510 array( 'status' => 400 )
511 );
512 }
513 if ( ! validate_username( $username ) ) {
514 return new WP_Error(
515 'openstation_users_username_invalid',
516 __( 'Username is not valid.', 'desktop-mode' ),
517 array( 'status' => 400 )
518 );
519 }
520 if ( '' === $email || ! is_email( $email ) ) {
521 return new WP_Error(
522 'openstation_users_email_invalid',
523 __( 'A valid email address is required.', 'desktop-mode' ),
524 array( 'status' => 400 )
525 );
526 }
527 if ( username_exists( $username ) ) {
528 return new WP_Error(
529 'openstation_users_username_exists',
530 __( 'That username is already in use.', 'desktop-mode' ),
531 array( 'status' => 409 )
532 );
533 }
534 if ( email_exists( $email ) ) {
535 return new WP_Error(
536 'openstation_users_email_exists',
537 __( 'That email is already in use.', 'desktop-mode' ),
538 array( 'status' => 409 )
539 );
540 }
541
542 // Role gate. Empty role → fall back to the site default. A
543 // non-empty role MUST be in `editable_roles()` for the requester
544 // — same protection as the bulk-role endpoint, applied at create
545 // time so an Editor can't create an Administrator.
546 if ( '' === $role ) {
547 $role = (string) get_option( 'default_role', 'subscriber' );
548 }
549 $assignable = openstation_users_window_assignable_roles( (int) get_current_user_id() );
550 // `openstation_users_window_assignable_roles` is gated on
551 // `promote_users` — viewers with `create_users` but not
552 // `promote_users` need a fallback. Allow them to assign the
553 // default role only.
554 if ( empty( $assignable ) ) {
555 $assignable = array( (string) get_option( 'default_role', 'subscriber' ) );
556 }
557 if ( ! in_array( $role, $assignable, true ) ) {
558 return new WP_Error(
559 'openstation_users_role_forbidden',
560 __( 'You are not allowed to assign that role.', 'desktop-mode' ),
561 array( 'status' => 403 )
562 );
563 }
564
565 // Auto-generate a password when none supplied; matches core's
566 // classic behaviour. The user can complete the password reset
567 // via the email notification.
568 if ( '' === $password ) {
569 $password = wp_generate_password( 24, true, true );
570 }
571
572 $userdata = array(
573 'user_login' => $username,
574 'user_email' => $email,
575 'user_pass' => $password,
576 'first_name' => $first,
577 'last_name' => $last,
578 'user_url' => $url,
579 'role' => $role,
580 );
581
582 $user_id = wp_insert_user( $userdata );
583 if ( is_wp_error( $user_id ) ) {
584 // Keep core's error code so the JS can map common cases
585 // (`existing_user_login`, `existing_user_email`) to
586 // localized messages.
587 return $user_id;
588 }
589
590 // Locale (post-create — `wp_insert_user` doesn't take it).
591 if ( '' !== $locale ) {
592 $locale_slugs = array_keys( openstation_users_window_locales_map() );
593 if ( in_array( $locale, $locale_slugs, true ) ) {
594 update_user_meta( (int) $user_id, 'locale', $locale );
595 }
596 }
597
598 if ( $notify ) {
599 // `'both'` — admin + user. Same flag classic users.php sets
600 // when "Send the new user an email about their account" is
601 // checked.
602 wp_new_user_notification( (int) $user_id, null, 'both' );
603 }
604
605 /**
606 * Fires after the Users window has created a new account.
607 *
608 * @param int $user_id
609 * @param WP_User $user Wrapped user object.
610 * @param array $args Sanitized args used for creation.
611 */
612 do_action(
613 'openstation_users_window_user_created',
614 (int) $user_id,
615 get_userdata( (int) $user_id ),
616 $userdata
617 );
618
619 return rest_ensure_response(
620 array(
621 'ok' => true,
622 'user_id' => (int) $user_id,
623 'email' => $email,
624 )
625 );
626 }
627