PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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 / apps / users / parts / rest.php

rest.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.10, at apps/users/parts/rest.php

511 lines 17.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Users app — the mutations, and the REST routes that expose them.
4 *
5 * Five operations, each a plain function the app's actions call
6 * directly and a `desktop-mode/v1` route wraps for other consumers:
7 *
8 * - POST /users/bulk-role { ids: int[], role: string }
9 * - POST /users/<id>/send-password-reset
10 * - POST /users/<id>/resend-welcome
11 * - POST /users { username, email, role?, … }
12 * - POST /users/bulk-delete { ids: int[], reassign?: int }
13 *
14 * SECURITY POSTURE
15 * ================
16 *
17 * Every path does TWO checks: the broad cap gate (`promote_users`,
18 * `edit_users`, `create_users`, `delete_users` / `remove_users`) —
19 * the route's `permission_callback`, the action's own check — and a
20 * per-target re-validation inside the function: bulk-role and create
21 * validate the requested role against the filtered
22 * `openstation_users_window_assignable_roles()` list; bulk-delete
23 * checks `delete_user` / `remove_user` per row; self-targeting is
24 * refused on operations that could lock the requester out.
25 *
26 * @package OpenStation
27 */
28
29 defined( 'ABSPATH' ) || exit;
30
31 /**
32 * Positive integer ids out of a request value, capped so a runaway
33 * client can't flood `wp_update_user` calls in one request.
34 *
35 * @param mixed $raw Anything a client sent.
36 * @return int[]
37 */
38 function openstation_users_window_clean_ids( $raw ) {
39 $ids = array_values(
40 array_filter(
41 array_map( 'intval', (array) $raw ),
42 static function ( $id ) {
43 return $id > 0;
44 }
45 )
46 );
47 return array_slice( $ids, 0, 100 );
48 }
49
50 /**
51 * Set one role on every id the viewer may edit.
52 *
53 * Partial success is the norm — a request to promote five users
54 * where the requester can edit four of them succeeds for those four
55 * and reports `forbidden` for the fifth.
56 *
57 * @param int[] $ids Target user ids.
58 * @param string $role Role slug.
59 * @return array{role:string,results:array<string,array{ok:bool,error?:string}>}|WP_Error
60 */
61 function openstation_users_window_apply_bulk_role( array $ids, $role ) {
62 $ids = openstation_users_window_clean_ids( $ids );
63 $role = sanitize_key( (string) $role );
64 if ( empty( $ids ) ) {
65 return new WP_Error( 'openstation_users_no_ids', __( 'No user ids supplied.', 'desktop-mode' ), array( 'status' => 400 ) );
66 }
67 $viewer_id = (int) get_current_user_id();
68 if ( ! in_array( $role, openstation_users_window_assignable_roles( $viewer_id ), true ) ) {
69 return new WP_Error( 'openstation_users_role_forbidden', __( 'You are not allowed to assign this role.', 'desktop-mode' ), array( 'status' => 403 ) );
70 }
71
72 $results = array();
73 foreach ( $ids as $id ) {
74 // `edit_user` encapsulates "can the viewer manage this user?".
75 if ( ! current_user_can( 'edit_user', $id ) ) {
76 $results[ (string) $id ] = array(
77 'ok' => false,
78 'error' => 'forbidden',
79 );
80 continue;
81 }
82 $user = get_userdata( $id );
83 if ( ! $user instanceof WP_User ) {
84 $results[ (string) $id ] = array(
85 'ok' => false,
86 'error' => 'not_found',
87 );
88 continue;
89 }
90 // Self-demotion guard: don't let the requester strip their own
91 // admin role and lock themselves out (core's users.php posture).
92 if ( $id === $viewer_id && in_array( 'administrator', (array) $user->roles, true ) && 'administrator' !== $role ) {
93 $results[ (string) $id ] = array(
94 'ok' => false,
95 'error' => 'self_demote',
96 );
97 continue;
98 }
99 // `set_role` replaces all roles with the single new one —
100 // the classic "Change role to…" semantics.
101 $user->set_role( $role );
102 $results[ (string) $id ] = array( 'ok' => true );
103 }
104
105 return array(
106 'role' => $role,
107 'results' => $results,
108 );
109 }
110
111 /**
112 * The target of a per-user email operation, or the error saying why not.
113 *
114 * @param int $id Target user id.
115 * @param string $message The 403 message.
116 * @return WP_User|WP_Error
117 */
118 function openstation_users_window_email_target( $id, $message ) {
119 $id = (int) $id;
120 $user = $id > 0 ? get_userdata( $id ) : null;
121 if ( ! $user instanceof WP_User ) {
122 return new WP_Error( 'openstation_users_not_found', __( 'User not found.', 'desktop-mode' ), array( 'status' => 404 ) );
123 }
124 if ( ! current_user_can( 'edit_user', $id ) ) {
125 return new WP_Error( 'openstation_users_forbidden', $message, array( 'status' => 403 ) );
126 }
127 return $user;
128 }
129
130 /**
131 * At most one email per (requester, target) pair per minute — stops
132 * accidental double-clicks from firing two emails AND closes a small
133 * abuse vector where an admin bot account could spam a victim.
134 *
135 * @param string $kind `pw_reset` | `welcome`.
136 * @param int $id Target user id.
137 * @param string $message The 429 message.
138 * @return WP_Error|null
139 */
140 function openstation_users_window_email_throttle( $kind, $id, $message ) {
141 $key = sprintf( '_dm_%s_throttle_%d_%d', $kind, (int) get_current_user_id(), (int) $id );
142 $last = (int) get_transient( $key );
143 if ( $last > 0 && ( time() - $last ) < 60 ) {
144 return new WP_Error( 'openstation_users_throttled', $message, array( 'status' => 429 ) );
145 }
146 set_transient( $key, time(), MINUTE_IN_SECONDS );
147 return null;
148 }
149
150 /**
151 * Email a password-reset link — core's `retrieve_password()`, so the
152 * email matches the login screen's "Lost your password?" flow.
153 *
154 * @param int $id Target user id.
155 * @return array{ok:bool,email:string}|WP_Error
156 */
157 function openstation_users_window_send_password_reset( $id ) {
158 $user = openstation_users_window_email_target( $id, __( 'You are not allowed to send a password reset for this user.', 'desktop-mode' ) );
159 if ( is_wp_error( $user ) ) {
160 return $user;
161 }
162 $throttled = openstation_users_window_email_throttle( 'pw_reset', $id, __( 'A reset email was already sent recently. Try again in a minute.', 'desktop-mode' ) );
163 if ( $throttled ) {
164 return $throttled;
165 }
166 $result = retrieve_password( $user->user_login );
167 if ( is_wp_error( $result ) ) {
168 return $result;
169 }
170 return array(
171 'ok' => true,
172 'email' => $user->user_email,
173 );
174 }
175
176 /**
177 * Re-send the new-user notification email (the user keeps their
178 * credentials — this resends the WELCOME email, not a password).
179 *
180 * @param int $id Target user id.
181 * @return array{ok:bool,email:string}|WP_Error
182 */
183 function openstation_users_window_resend_welcome( $id ) {
184 $user = openstation_users_window_email_target( $id, __( 'You are not allowed to email this user.', 'desktop-mode' ) );
185 if ( is_wp_error( $user ) ) {
186 return $user;
187 }
188 $throttled = openstation_users_window_email_throttle( 'welcome', $id, __( 'A welcome email was already sent recently. Try again in a minute.', 'desktop-mode' ) );
189 if ( $throttled ) {
190 return $throttled;
191 }
192 wp_new_user_notification( (int) $id, null, 'user' );
193 return array(
194 'ok' => true,
195 'email' => $user->user_email,
196 );
197 }
198
199 /**
200 * Delete (single-site) or remove from the current site (multisite)
201 * every id the viewer may, optionally reassigning content.
202 *
203 * @param int[] $ids Target user ids.
204 * @param int $reassign User id to reassign content to, 0 for none.
205 * @return array{results:array<string,array{ok:bool,error?:string}>}|WP_Error
206 */
207 function openstation_users_window_apply_bulk_delete( array $ids, $reassign = 0 ) {
208 $ids = openstation_users_window_clean_ids( $ids );
209 $reassign = (int) $reassign;
210 $viewer_id = (int) get_current_user_id();
211 if ( empty( $ids ) ) {
212 return new WP_Error( 'openstation_users_no_ids', __( 'No user ids supplied.', 'desktop-mode' ), array( 'status' => 400 ) );
213 }
214 if ( ! function_exists( 'wp_delete_user' ) ) {
215 require_once ABSPATH . 'wp-admin/includes/user.php';
216 }
217
218 $results = array();
219 foreach ( $ids as $id ) {
220 // Self-delete guard — same posture as core's classic users.php.
221 if ( $id === $viewer_id ) {
222 $results[ (string) $id ] = array(
223 'ok' => false,
224 'error' => 'self_delete',
225 );
226 continue;
227 }
228 $multisite = is_multisite();
229 if ( ! current_user_can( $multisite ? 'remove_user' : 'delete_user', $id ) ) {
230 $results[ (string) $id ] = array(
231 'ok' => false,
232 'error' => 'forbidden',
233 );
234 continue;
235 }
236 $ok = $multisite
237 ? remove_user_from_blog( $id, get_current_blog_id(), $reassign > 0 ? $reassign : null )
238 : wp_delete_user( $id, $reassign > 0 ? $reassign : null );
239 $results[ (string) $id ] = $ok && ! is_wp_error( $ok )
240 ? array( 'ok' => true )
241 : array(
242 'ok' => false,
243 'error' => $multisite ? 'remove_failed' : 'delete_failed',
244 );
245 }
246
247 return array( 'results' => $results );
248 }
249
250 /**
251 * Create a WordPress user from the Add User form's fields.
252 *
253 * Mirrors the field set core gathers in `wp-admin/user-new.php`.
254 * Per-target gates in addition to `create_users`: the role must be
255 * assignable by the requester (an Editor can't create an
256 * Administrator); the user must not already exist by username OR
257 * email; inputs go through core's sanitizers.
258 *
259 * @param array<string,mixed> $args `username`, `email`, `first_name`, `last_name`, `url`, `locale`, `password`, `role`, `send_notification`.
260 * @return array{ok:bool,user_id:int,email:string}|WP_Error
261 */
262 function openstation_users_window_create_user( array $args ) {
263 $username = sanitize_user( (string) ( $args['username'] ?? '' ), true );
264 $email = sanitize_email( (string) ( $args['email'] ?? '' ) );
265 $locale = (string) ( $args['locale'] ?? '' );
266 $password = (string) ( $args['password'] ?? '' );
267 $role = sanitize_key( (string) ( $args['role'] ?? '' ) );
268 $notify = ! empty( $args['send_notification'] );
269
270 if ( '' === $username ) {
271 return new WP_Error( 'openstation_users_username_required', __( 'Username is required.', 'desktop-mode' ), array( 'status' => 400 ) );
272 }
273 if ( ! validate_username( $username ) ) {
274 return new WP_Error( 'openstation_users_username_invalid', __( 'Username is not valid.', 'desktop-mode' ), array( 'status' => 400 ) );
275 }
276 if ( '' === $email || ! is_email( $email ) ) {
277 return new WP_Error( 'openstation_users_email_invalid', __( 'A valid email address is required.', 'desktop-mode' ), array( 'status' => 400 ) );
278 }
279 if ( username_exists( $username ) ) {
280 return new WP_Error( 'openstation_users_username_exists', __( 'That username is already in use.', 'desktop-mode' ), array( 'status' => 409 ) );
281 }
282 if ( email_exists( $email ) ) {
283 return new WP_Error( 'openstation_users_email_exists', __( 'That email is already in use.', 'desktop-mode' ), array( 'status' => 409 ) );
284 }
285
286 // Role gate. Empty role → the site default. A non-empty role MUST
287 // be assignable by the requester. Viewers with `create_users` but
288 // not `promote_users` may assign the default role only.
289 $default_role = (string) get_option( 'default_role', 'subscriber' );
290 if ( '' === $role ) {
291 $role = $default_role;
292 }
293 $assignable = openstation_users_window_assignable_roles( (int) get_current_user_id() );
294 if ( empty( $assignable ) ) {
295 $assignable = array( $default_role );
296 }
297 if ( ! in_array( $role, $assignable, true ) ) {
298 return new WP_Error( 'openstation_users_role_forbidden', __( 'You are not allowed to assign that role.', 'desktop-mode' ), array( 'status' => 403 ) );
299 }
300
301 $userdata = array(
302 'user_login' => $username,
303 'user_email' => $email,
304 'user_pass' => '' === $password ? wp_generate_password( 24, true, true ) : $password,
305 'first_name' => sanitize_text_field( (string) ( $args['first_name'] ?? '' ) ),
306 'last_name' => sanitize_text_field( (string) ( $args['last_name'] ?? '' ) ),
307 'user_url' => esc_url_raw( (string) ( $args['url'] ?? '' ) ),
308 'role' => $role,
309 );
310
311 $user_id = wp_insert_user( $userdata );
312 if ( is_wp_error( $user_id ) ) {
313 // Core's error code lets the client map `existing_user_login`
314 // / `existing_user_email` to localized messages.
315 return $user_id;
316 }
317
318 // Locale (post-create — `wp_insert_user` doesn't take it).
319 if ( '' !== $locale && in_array( $locale, array_keys( openstation_users_window_locales_map() ), true ) ) {
320 update_user_meta( (int) $user_id, 'locale', $locale );
321 }
322 if ( $notify ) {
323 // `'both'` — admin + user, the flag classic users.php sets when
324 // "Send the new user an email about their account" is checked.
325 wp_new_user_notification( (int) $user_id, null, 'both' );
326 }
327
328 /**
329 * Fires after the Users window has created a new account.
330 *
331 * @param int $user_id
332 * @param WP_User $user Wrapped user object.
333 * @param array $args Sanitized args used for creation.
334 */
335 do_action( 'openstation_users_window_user_created', (int) $user_id, get_userdata( (int) $user_id ), $userdata );
336
337 return array(
338 'ok' => true,
339 'user_id' => (int) $user_id,
340 'email' => $email,
341 );
342 }
343
344 // --------------------------------------------------------------- routes
345
346 /**
347 * Register the five routes.
348 */
349 function openstation_users_window_register_rest_routes() {
350 $id_arg = array(
351 'id' => array(
352 'required' => true,
353 'type' => 'integer',
354 ),
355 );
356 $ids = array(
357 'required' => true,
358 'type' => 'array',
359 'items' => array( 'type' => 'integer' ),
360 );
361
362 register_rest_route(
363 'desktop-mode/v1',
364 '/users/bulk-role',
365 array(
366 'methods' => WP_REST_Server::CREATABLE,
367 'callback' => 'openstation_users_window_rest_bulk_role',
368 'permission_callback' => static function () {
369 return current_user_can( 'promote_users' );
370 },
371 'args' => array(
372 'ids' => $ids,
373 'role' => array(
374 'required' => true,
375 'type' => 'string',
376 ),
377 ),
378 )
379 );
380 register_rest_route(
381 'desktop-mode/v1',
382 '/users/(?P<id>\d+)/send-password-reset',
383 array(
384 'methods' => WP_REST_Server::CREATABLE,
385 'callback' => 'openstation_users_window_rest_send_password_reset',
386 'permission_callback' => static function () {
387 return current_user_can( 'edit_users' );
388 },
389 'args' => $id_arg,
390 )
391 );
392 register_rest_route(
393 'desktop-mode/v1',
394 '/users/(?P<id>\d+)/resend-welcome',
395 array(
396 'methods' => WP_REST_Server::CREATABLE,
397 'callback' => 'openstation_users_window_rest_resend_welcome',
398 'permission_callback' => static function () {
399 return current_user_can( 'edit_users' );
400 },
401 'args' => $id_arg,
402 )
403 );
404 register_rest_route(
405 'desktop-mode/v1',
406 '/users',
407 array(
408 'methods' => WP_REST_Server::CREATABLE,
409 'callback' => 'openstation_users_window_rest_create',
410 'permission_callback' => static function () {
411 return current_user_can( 'create_users' );
412 },
413 'args' => array(
414 'username' => array(
415 'required' => true,
416 'type' => 'string',
417 ),
418 'email' => array(
419 'required' => true,
420 'type' => 'string',
421 ),
422 'first_name' => array( 'type' => 'string' ),
423 'last_name' => array( 'type' => 'string' ),
424 'url' => array( 'type' => 'string' ),
425 'locale' => array( 'type' => 'string' ),
426 'password' => array( 'type' => 'string' ),
427 'role' => array( 'type' => 'string' ),
428 'send_notification' => array( 'type' => 'boolean' ),
429 ),
430 )
431 );
432 register_rest_route(
433 'desktop-mode/v1',
434 '/users/bulk-delete',
435 array(
436 'methods' => WP_REST_Server::CREATABLE,
437 'callback' => 'openstation_users_window_rest_bulk_delete',
438 'permission_callback' => static function () {
439 return is_multisite() ? current_user_can( 'remove_users' ) : current_user_can( 'delete_users' );
440 },
441 'args' => array(
442 'ids' => $ids,
443 'reassign' => array(
444 'required' => false,
445 'type' => 'integer',
446 ),
447 ),
448 )
449 );
450 }
451 add_action( 'rest_api_init', 'openstation_users_window_register_rest_routes' );
452
453 /**
454 * `POST /users/bulk-role`.
455 *
456 * @param WP_REST_Request $req Request.
457 * @return WP_REST_Response|WP_Error
458 */
459 function openstation_users_window_rest_bulk_role( $req ) {
460 $result = openstation_users_window_apply_bulk_role( (array) $req->get_param( 'ids' ), (string) $req->get_param( 'role' ) );
461 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
462 }
463
464 /**
465 * `POST /users/<id>/send-password-reset`.
466 *
467 * @param WP_REST_Request $req Request.
468 * @return WP_REST_Response|WP_Error
469 */
470 function openstation_users_window_rest_send_password_reset( $req ) {
471 $result = openstation_users_window_send_password_reset( (int) $req->get_param( 'id' ) );
472 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
473 }
474
475 /**
476 * `POST /users/<id>/resend-welcome`.
477 *
478 * @param WP_REST_Request $req Request.
479 * @return WP_REST_Response|WP_Error
480 */
481 function openstation_users_window_rest_resend_welcome( $req ) {
482 $result = openstation_users_window_resend_welcome( (int) $req->get_param( 'id' ) );
483 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
484 }
485
486 /**
487 * `POST /users/bulk-delete`.
488 *
489 * @param WP_REST_Request $req Request.
490 * @return WP_REST_Response|WP_Error
491 */
492 function openstation_users_window_rest_bulk_delete( $req ) {
493 $result = openstation_users_window_apply_bulk_delete( (array) $req->get_param( 'ids' ), (int) $req->get_param( 'reassign' ) );
494 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
495 }
496
497 /**
498 * `POST /users` — create a new WordPress user.
499 *
500 * @param WP_REST_Request $req Request.
501 * @return WP_REST_Response|WP_Error
502 */
503 function openstation_users_window_rest_create( $req ) {
504 $args = array();
505 foreach ( array( 'username', 'email', 'first_name', 'last_name', 'url', 'locale', 'password', 'role', 'send_notification' ) as $key ) {
506 $args[ $key ] = $req->get_param( $key );
507 }
508 $result = openstation_users_window_create_user( $args );
509 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
510 }
511