PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.5
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.5
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 / user-edit-window / rest.php

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

724 lines 23.2 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 User Edit Window: insights endpoint.
4 *
5 * `GET /desktop-mode/v1/users/<id>/insights` — returns a single
6 * payload with everything the Insights tab needs:
7 *
8 * - profileCompleteness: filled vs total core fields, percent
9 * - stats: posts / pages / comments / media / approved-comments
10 * received on own posts / days since registration / last login
11 * - contentByMonth: last 12 months of posts authored (for the
12 * mini activity chart)
13 * - recentPosts: last 5 posts the user authored, with status +
14 * comment count
15 * - recentComments: last 5 comments the user wrote (NOT comments
16 * ON their posts — comments BY them, including on their own
17 * content)
18 * - sessions: count of active session tokens (from
19 * `WP_Session_Tokens`); `current` flagged when known
20 * - applicationPasswords: count + most-recently-used summary
21 * - lastLoginAt: UTC unix timestamp from
22 * `_desktop_mode_last_login_at` user meta
23 *
24 * Server-side caching: each user's insights are computed at most
25 * once per minute (transient cache keyed by user_id). The numbers
26 * are eventually-consistent — cheaper than re-running 6 SQL
27 * aggregates on every form interaction.
28 *
29 * @package WPDesktopMode
30 * @since 0.8.1
31 */
32
33 defined( 'ABSPATH' ) || exit;
34
35 /**
36 * Register the route.
37 *
38 * @since 0.8.1
39 */
40 function desktop_mode_user_edit_window_register_rest_routes() {
41 register_rest_route(
42 'desktop-mode/v1',
43 '/users/(?P<id>\d+)/insights',
44 array(
45 'methods' => WP_REST_Server::READABLE,
46 'callback' => 'desktop_mode_user_edit_window_rest_insights',
47 'permission_callback' => static function ( $req ) {
48 $id = (int) $req->get_param( 'id' );
49 return desktop_mode_user_edit_window_can_edit(
50 (int) get_current_user_id(),
51 $id
52 );
53 },
54 'args' => array(
55 'id' => array(
56 'required' => true,
57 'type' => 'integer',
58 ),
59 'fresh' => array(
60 'type' => 'boolean',
61 ),
62 ),
63 )
64 );
65 }
66 add_action( 'rest_api_init', 'desktop_mode_user_edit_window_register_rest_routes' );
67
68 /**
69 * Register the personal-options user-meta keys with `show_in_rest`
70 * so the Profile form can save them via core's
71 * `PUT /wp/v2/users/<id>` `meta` field.
72 *
73 * Without this, the meta keys exist (core uses them on the
74 * classic profile.php save) but the REST controller ignores
75 * `meta.rich_editing` etc. on update.
76 *
77 * @since 0.8.1
78 */
79 function desktop_mode_user_edit_window_register_meta() {
80 $keys = array(
81 'rich_editing' => 'string',
82 'syntax_highlighting' => 'string',
83 'admin_color' => 'string',
84 'comment_shortcuts' => 'string',
85 'show_admin_bar_front' => 'string',
86 );
87 foreach ( $keys as $meta_key => $type ) {
88 register_meta(
89 'user',
90 $meta_key,
91 array(
92 'type' => $type,
93 'single' => true,
94 'show_in_rest' => array(
95 'schema' => array(
96 'type' => $type,
97 'context' => array( 'view', 'edit' ),
98 ),
99 ),
100 'auth_callback' => static function ( $allowed, $meta_key2, $user_id ) {
101 unset( $meta_key2 );
102 return current_user_can( 'edit_user', (int) $user_id );
103 },
104 'sanitize_callback' => 'sanitize_text_field',
105 )
106 );
107 }
108 }
109 add_action( 'init', 'desktop_mode_user_edit_window_register_meta' );
110
111 /**
112 * `POST /users/<id>/destroy-other-sessions` — log out everywhere
113 * else (current device kept). Mirrors the WP-core
114 * `destroy-sessions` AJAX action.
115 *
116 * @since 0.8.1
117 */
118 function desktop_mode_user_edit_window_destroy_sessions_route() {
119 register_rest_route(
120 'desktop-mode/v1',
121 '/users/(?P<id>\d+)/destroy-sessions',
122 array(
123 'methods' => WP_REST_Server::CREATABLE,
124 'callback' => 'desktop_mode_user_edit_window_rest_destroy_sessions',
125 'permission_callback' => static function ( $req ) {
126 $id = (int) $req->get_param( 'id' );
127 return desktop_mode_user_edit_window_can_edit(
128 (int) get_current_user_id(),
129 $id
130 );
131 },
132 'args' => array(
133 'id' => array( 'required' => true, 'type' => 'integer' ),
134 'scope' => array( 'type' => 'string', 'default' => 'others' ),
135 ),
136 )
137 );
138 }
139 add_action( 'rest_api_init', 'desktop_mode_user_edit_window_destroy_sessions_route' );
140
141 function desktop_mode_user_edit_window_rest_destroy_sessions( $req ) {
142 $id = (int) $req->get_param( 'id' );
143 $scope = (string) $req->get_param( 'scope' );
144 if ( ! class_exists( 'WP_Session_Tokens' ) ) {
145 return new WP_Error(
146 'desktop_mode_users_no_sessions',
147 __( 'Session manager unavailable.', 'desktop-mode' ),
148 array( 'status' => 500 )
149 );
150 }
151 $manager = WP_Session_Tokens::get_instance( $id );
152 if ( $scope === 'all' || $id !== (int) get_current_user_id() ) {
153 // Editing another user — destroy ALL of their sessions.
154 // Editing self with scope='all' — destroy all (including
155 // the current). Note the latter logs the requester out.
156 $manager->destroy_all();
157 } else {
158 $manager->destroy_others( wp_get_session_token() );
159 }
160 // Bust the insights cache so the sessions count refreshes.
161 delete_transient( 'dm_user_insights_' . $id );
162 return rest_ensure_response( array( 'ok' => true ) );
163 }
164
165 /**
166 * `GET /users/<id>/application-passwords` — list app passwords.
167 * `POST /users/<id>/application-passwords` — create new.
168 * `DELETE /users/<id>/application-passwords/<uuid>` — revoke one.
169 *
170 * Thin wrappers over `WP_Application_Passwords` so the form has a
171 * single REST surface to talk to.
172 *
173 * @since 0.8.1
174 */
175 function desktop_mode_user_edit_window_app_passwords_routes() {
176 register_rest_route(
177 'desktop-mode/v1',
178 '/users/(?P<id>\d+)/application-passwords',
179 array(
180 array(
181 'methods' => WP_REST_Server::READABLE,
182 'callback' => 'desktop_mode_user_edit_window_rest_app_pw_list',
183 'permission_callback' => static function ( $req ) {
184 return desktop_mode_user_edit_window_can_edit(
185 (int) get_current_user_id(),
186 (int) $req->get_param( 'id' )
187 );
188 },
189 ),
190 array(
191 'methods' => WP_REST_Server::CREATABLE,
192 'callback' => 'desktop_mode_user_edit_window_rest_app_pw_create',
193 'permission_callback' => static function ( $req ) {
194 return desktop_mode_user_edit_window_can_edit(
195 (int) get_current_user_id(),
196 (int) $req->get_param( 'id' )
197 );
198 },
199 'args' => array(
200 'name' => array( 'required' => true, 'type' => 'string' ),
201 ),
202 ),
203 )
204 );
205 register_rest_route(
206 'desktop-mode/v1',
207 '/users/(?P<id>\d+)/application-passwords/(?P<uuid>[a-f0-9-]+)',
208 array(
209 'methods' => WP_REST_Server::DELETABLE,
210 'callback' => 'desktop_mode_user_edit_window_rest_app_pw_revoke',
211 'permission_callback' => static function ( $req ) {
212 return desktop_mode_user_edit_window_can_edit(
213 (int) get_current_user_id(),
214 (int) $req->get_param( 'id' )
215 );
216 },
217 )
218 );
219 }
220 add_action( 'rest_api_init', 'desktop_mode_user_edit_window_app_passwords_routes' );
221
222 /**
223 * Enforce core's application-password availability policy for a
224 * target user. Mirrors `WP_REST_Application_Passwords_Controller`'s
225 * permission check: every operation is rejected when the feature is
226 * disabled site-wide (`wp_is_application_passwords_available()`) or
227 * for the target user
228 * (`wp_is_application_passwords_available_for_user()`) — both of
229 * which are filterable by security plugins.
230 *
231 * @param int $user_id Target user id.
232 * @return WP_Error|null Error when unavailable, null when allowed.
233 */
234 function desktop_mode_user_edit_window_app_pw_unavailable( $user_id ) {
235 if (
236 ! function_exists( 'wp_is_application_passwords_available' )
237 || ! wp_is_application_passwords_available()
238 || ! wp_is_application_passwords_available_for_user( (int) $user_id )
239 ) {
240 return new WP_Error(
241 'desktop_mode_users_app_pw_unavailable',
242 __( 'Application passwords are not available for this user.', 'desktop-mode' ),
243 array( 'status' => 501 )
244 );
245 }
246 return null;
247 }
248
249 function desktop_mode_user_edit_window_rest_app_pw_list( $req ) {
250 if ( ! class_exists( 'WP_Application_Passwords' ) ) {
251 return rest_ensure_response( array( 'items' => array() ) );
252 }
253 $id = (int) $req->get_param( 'id' );
254 $unavailable = desktop_mode_user_edit_window_app_pw_unavailable( $id );
255 if ( is_wp_error( $unavailable ) ) {
256 return $unavailable;
257 }
258 $apps = (array) WP_Application_Passwords::get_user_application_passwords( $id );
259 return rest_ensure_response( array( 'items' => $apps ) );
260 }
261
262 function desktop_mode_user_edit_window_rest_app_pw_create( $req ) {
263 if ( ! class_exists( 'WP_Application_Passwords' ) ) {
264 return new WP_Error(
265 'desktop_mode_users_app_pw_unavailable',
266 __( 'Application passwords are not available on this site.', 'desktop-mode' ),
267 array( 'status' => 501 )
268 );
269 }
270 $id = (int) $req->get_param( 'id' );
271 $unavailable = desktop_mode_user_edit_window_app_pw_unavailable( $id );
272 if ( is_wp_error( $unavailable ) ) {
273 return $unavailable;
274 }
275 $name = sanitize_text_field( (string) $req->get_param( 'name' ) );
276 if ( '' === $name ) {
277 return new WP_Error(
278 'desktop_mode_users_app_pw_name_required',
279 __( 'Application password name is required.', 'desktop-mode' ),
280 array( 'status' => 400 )
281 );
282 }
283 $created = WP_Application_Passwords::create_new_application_password( $id, array( 'name' => $name ) );
284 if ( is_wp_error( $created ) ) {
285 return $created;
286 }
287 list( $unhashed_password, $item ) = $created;
288 delete_transient( 'dm_user_insights_' . $id );
289 return rest_ensure_response(
290 array(
291 'ok' => true,
292 'password' => $unhashed_password,
293 'item' => $item,
294 )
295 );
296 }
297
298 function desktop_mode_user_edit_window_rest_app_pw_revoke( $req ) {
299 if ( ! class_exists( 'WP_Application_Passwords' ) ) {
300 return new WP_Error(
301 'desktop_mode_users_app_pw_unavailable',
302 __( 'Application passwords are not available on this site.', 'desktop-mode' ),
303 array( 'status' => 501 )
304 );
305 }
306 $id = (int) $req->get_param( 'id' );
307 $unavailable = desktop_mode_user_edit_window_app_pw_unavailable( $id );
308 if ( is_wp_error( $unavailable ) ) {
309 return $unavailable;
310 }
311 $uuid = (string) $req->get_param( 'uuid' );
312 $ok = WP_Application_Passwords::delete_application_password( $id, $uuid );
313 if ( is_wp_error( $ok ) ) {
314 return $ok;
315 }
316 delete_transient( 'dm_user_insights_' . $id );
317 return rest_ensure_response( array( 'ok' => true ) );
318 }
319
320 /**
321 * `GET /users/<id>/insights` callback.
322 *
323 * @since 0.8.1
324 *
325 * @param WP_REST_Request $req
326 * @return WP_REST_Response|WP_Error
327 */
328 function desktop_mode_user_edit_window_rest_insights( $req ) {
329 $id = (int) $req->get_param( 'id' );
330 $user = $id > 0 ? get_userdata( $id ) : null;
331 if ( ! $user instanceof WP_User ) {
332 return new WP_Error(
333 'desktop_mode_users_not_found',
334 __( 'User not found.', 'desktop-mode' ),
335 array( 'status' => 404 )
336 );
337 }
338
339 $fresh = (bool) $req->get_param( 'fresh' );
340 $cache_key = 'dm_user_insights_' . $id;
341 $payload = null;
342 if ( ! $fresh ) {
343 $cached = get_transient( $cache_key );
344 if ( is_array( $cached ) ) {
345 $payload = $cached;
346 }
347 }
348 if ( null === $payload ) {
349 $payload = desktop_mode_user_edit_window_compute_insights( $user );
350 }
351
352 // Self-view override — the viewer is by definition logged in
353 // right now since they're staring at their own profile. If the
354 // `_desktop_mode_last_login_at` meta isn't populated yet
355 // (account predates the plugin install, or the wp_login hook
356 // fires after the first profile open on a single-tick login)
357 // the tile would read "Never" — contradicting the obvious. Pin
358 // to current time and lazily backfill the meta so future reads
359 // no longer depend on this override. Lives OUTSIDE
360 // `compute_insights` so it applies on cache hits AS WELL AS
361 // fresh computes.
362 $viewer_id = (int) get_current_user_id();
363 if ( $id === $viewer_id ) {
364 $now = time();
365 $stored = (int) get_user_meta(
366 $id,
367 defined( 'DESKTOP_MODE_LAST_LOGIN_META_KEY' )
368 ? DESKTOP_MODE_LAST_LOGIN_META_KEY
369 : '_desktop_mode_last_login_at',
370 true
371 );
372 if ( $stored <= 0 ) {
373 update_user_meta(
374 $id,
375 defined( 'DESKTOP_MODE_LAST_LOGIN_META_KEY' )
376 ? DESKTOP_MODE_LAST_LOGIN_META_KEY
377 : '_desktop_mode_last_login_at',
378 $now
379 );
380 $stored = $now;
381 }
382 // Always reflect the truth on the payload — cached payloads
383 // from before the meta-backfill would otherwise still carry
384 // the stale `null`.
385 if ( ! isset( $payload['stats'] ) || ! is_array( $payload['stats'] ) ) {
386 $payload['stats'] = array();
387 }
388 if ( empty( $payload['stats']['lastLoginAt'] ) ) {
389 $payload['stats']['lastLoginAt'] = $stored;
390 $payload['stats']['daysSinceLastLogin'] = max(
391 0,
392 (int) floor( ( $now - $stored ) / DAY_IN_SECONDS )
393 );
394 }
395 }
396
397 /**
398 * Filter the insights payload before it's returned and cached.
399 *
400 * Plugins can append their own metrics (security-event counts,
401 * subscription tier, last-orders-placed, …) by extending the
402 * `stats` map or adding new top-level keys. The JS bundle
403 * tolerates unknown keys — they're surfaced as plugin tiles
404 * when they match the expected shape.
405 *
406 * @since 0.8.1
407 *
408 * @param array $payload Insights payload.
409 * @param WP_User $user Target user.
410 */
411 $payload = (array) apply_filters( 'desktop_mode_user_edit_window_insights', $payload, $user );
412
413 set_transient( $cache_key, $payload, MINUTE_IN_SECONDS );
414
415 return rest_ensure_response( $payload );
416 }
417
418 /**
419 * Compute the insights payload for a user. Centralized so plugins
420 * can call it directly from a custom REST route or admin notice
421 * without going through the HTTP cycle.
422 *
423 * @since 0.8.1
424 *
425 * @param WP_User $user
426 * @return array
427 */
428 function desktop_mode_user_edit_window_compute_insights( WP_User $user ) {
429 $id = (int) $user->ID;
430
431 // ── Profile completeness — count which core fields are non-empty.
432 $completeness_fields = array(
433 'first_name' => (string) $user->first_name,
434 'last_name' => (string) $user->last_name,
435 'nickname' => (string) $user->nickname,
436 'description' => (string) $user->description,
437 'user_url' => (string) $user->user_url,
438 'user_email' => (string) $user->user_email,
439 );
440 $filled = 0;
441 foreach ( $completeness_fields as $value ) {
442 if ( '' !== trim( $value ) ) {
443 $filled++;
444 }
445 }
446 $total = count( $completeness_fields );
447 $percent = $total > 0 ? (int) round( ( $filled / $total ) * 100 ) : 0;
448
449 // ── Per-CPT post counts. `count_user_posts` does the cheap thing.
450 $post_count = (int) count_user_posts( $id, 'post', true );
451 $page_count = post_type_exists( 'page' )
452 ? (int) count_user_posts( $id, 'page', true )
453 : 0;
454 $attachment_count = (int) count_user_posts( $id, 'attachment', true );
455
456 // ── Comments authored by this user (not received).
457 $comment_count = (int) get_comments(
458 array(
459 'user_id' => $id,
460 'count' => true,
461 )
462 );
463
464 // ── Approved comments RECEIVED on this user's published posts.
465 // Cheap aggregate — one COUNT, no row hydration.
466 global $wpdb;
467 $received_comments = (int) $wpdb->get_var(
468 $wpdb->prepare(
469 "SELECT COUNT(c.comment_ID)
470 FROM {$wpdb->comments} c
471 INNER JOIN {$wpdb->posts} p
472 ON p.ID = c.comment_post_ID
473 WHERE p.post_author = %d
474 AND p.post_status = 'publish'
475 AND c.comment_approved = '1'",
476 $id
477 )
478 );
479
480 // ── Months for the activity sparkline. Bucket published posts
481 // by year-month for the last 12 months. SQL bucket → align in PHP.
482 $month_buckets = array();
483 $now = time();
484 for ( $i = 11; $i >= 0; $i-- ) {
485 $ts = strtotime( "-{$i} months", $now );
486 $key = gmdate( 'Y-m', $ts );
487 $month_buckets[ $key ] = 0;
488 }
489 $rows = (array) $wpdb->get_results(
490 $wpdb->prepare(
491 "SELECT DATE_FORMAT( post_date_gmt, '%%Y-%%m' ) AS bucket,
492 COUNT(*) AS cnt
493 FROM {$wpdb->posts}
494 WHERE post_author = %d
495 AND post_status IN ( 'publish', 'private', 'future' )
496 AND post_date_gmt >= %s
497 GROUP BY bucket
498 ORDER BY bucket ASC",
499 $id,
500 gmdate( 'Y-m-01 00:00:00', strtotime( '-12 months', $now ) )
501 ),
502 ARRAY_A
503 );
504 foreach ( $rows as $row ) {
505 $bucket = isset( $row['bucket'] ) ? (string) $row['bucket'] : '';
506 if ( isset( $month_buckets[ $bucket ] ) ) {
507 $month_buckets[ $bucket ] = (int) $row['cnt'];
508 }
509 }
510 $content_by_month = array();
511 foreach ( $month_buckets as $bucket => $cnt ) {
512 $content_by_month[] = array(
513 'month' => $bucket,
514 'count' => $cnt,
515 );
516 }
517
518 // ── Recent posts (any status). Limit 5.
519 $recent_posts = array();
520 $recent = get_posts(
521 array(
522 'author' => $id,
523 'post_type' => 'any',
524 'post_status' => array(
525 'publish',
526 'draft',
527 'pending',
528 'future',
529 'private',
530 ),
531 'posts_per_page' => 5,
532 'orderby' => 'date',
533 'order' => 'DESC',
534 )
535 );
536 foreach ( $recent as $post ) {
537 // `post_date_gmt` is `'0000-00-00 00:00:00'` for drafts that
538 // have never been published — the JS Date.parse of that
539 // returns NaN, and the previous fallback rendered every
540 // draft's "recent activity" timestamp as "just now". Use
541 // `get_gmt_from_date( post_date )` to convert the always-set
542 // local `post_date` to UTC when the GMT field is zero.
543 $gmt = (string) $post->post_date_gmt;
544 if ( '' === $gmt || 0 === strpos( $gmt, '0000-00-00' ) ) {
545 $gmt = (string) get_gmt_from_date( (string) $post->post_date );
546 }
547 $recent_posts[] = array(
548 'id' => (int) $post->ID,
549 'title' => $post->post_title !== ''
550 ? $post->post_title
551 : __( '(no title)', 'desktop-mode' ),
552 'status' => (string) $post->post_status,
553 'type' => (string) $post->post_type,
554 'dateGmt' => $gmt,
555 'commentCount' => (int) $post->comment_count,
556 'permalink' => (string) get_permalink( $post ),
557 'editUrl' => (string) get_edit_post_link( $post->ID, 'raw' ),
558 );
559 }
560
561 // ── Recent comments authored by this user. Limit 5.
562 $recent_comments = array();
563 $comments = get_comments(
564 array(
565 'user_id' => $id,
566 'number' => 5,
567 'orderby' => 'comment_date_gmt',
568 'order' => 'DESC',
569 )
570 );
571 foreach ( (array) $comments as $comment ) {
572 $post_title = '';
573 if ( $comment->comment_post_ID ) {
574 $post = get_post( (int) $comment->comment_post_ID );
575 if ( $post instanceof WP_Post ) {
576 $post_title = $post->post_title !== ''
577 ? $post->post_title
578 : __( '(no title)', 'desktop-mode' );
579 }
580 }
581 // Same zero-date fallback as recent posts above.
582 $comment_gmt = (string) $comment->comment_date_gmt;
583 if ( '' === $comment_gmt || 0 === strpos( $comment_gmt, '0000-00-00' ) ) {
584 $comment_gmt = (string) get_gmt_from_date(
585 (string) $comment->comment_date
586 );
587 }
588 $recent_comments[] = array(
589 'id' => (int) $comment->comment_ID,
590 'postId' => (int) $comment->comment_post_ID,
591 'postTitle' => $post_title,
592 'excerpt' => wp_trim_words(
593 wp_strip_all_tags( (string) $comment->comment_content ),
594 24
595 ),
596 'dateGmt' => $comment_gmt,
597 'approved' => '1' === (string) $comment->comment_approved,
598 );
599 }
600
601 // ── Active sessions (`WP_Session_Tokens`). The token bag is a
602 // blob of metadata per device — UA / IP / login + expiration. We
603 // surface a per-session row plus a current-session flag.
604 $sessions = array();
605 if ( class_exists( 'WP_Session_Tokens' ) ) {
606 $manager = WP_Session_Tokens::get_instance( $id );
607 $current_token = wp_get_session_token();
608 // The meta blob's keys are *verifiers* — hashes of the raw
609 // cookie token (`WP_Session_Tokens::hash_token()`), so hash
610 // the current token the same way before comparing.
611 $current_verifier = '';
612 if ( $current_token ) {
613 $current_verifier = function_exists( 'hash' )
614 ? hash( 'sha256', $current_token )
615 : sha1( $current_token );
616 }
617 // `get_all` returns the tokens-as-array but doesn't expose
618 // the token id — peek into the meta blob via the user meta
619 // key directly so we can flag the "current" session.
620 $raw_tokens = (array) get_user_meta( $id, 'session_tokens', true );
621 // Prune expired entries the same way `WP_Session_Tokens`
622 // does on its own write path (so a user who hasn't logged
623 // in for a while doesn't show stale device rows).
624 $now_ts = time();
625 foreach ( $raw_tokens as $hash => $info ) {
626 if ( ! is_array( $info ) ) {
627 continue;
628 }
629 $expires = isset( $info['expiration'] ) ? (int) $info['expiration'] : 0;
630 if ( $expires > 0 && $expires < $now_ts ) {
631 continue;
632 }
633 $sessions[] = array(
634 'expiration' => $expires,
635 'login' => isset( $info['login'] ) ? (int) $info['login'] : 0,
636 'ip' => isset( $info['ip'] ) ? (string) $info['ip'] : '',
637 'ua' => isset( $info['ua'] ) ? (string) $info['ua'] : '',
638 'current' => '' !== $current_verifier && $current_verifier === $hash,
639 );
640 }
641 unset( $manager ); // unused but instantiated for symmetry / future use.
642 }
643
644 // ── Application passwords (WordPress 5.6+). Stored as user meta.
645 $app_passwords_summary = array(
646 'total' => 0,
647 'lastUsedAt' => null,
648 'lastUsedName' => null,
649 );
650 if ( class_exists( 'WP_Application_Passwords' ) ) {
651 $apps = WP_Application_Passwords::get_user_application_passwords( $id );
652 $apps = is_array( $apps ) ? $apps : array();
653 $app_passwords_summary['total'] = count( $apps );
654 $best_used_ts = 0;
655 $best_used_name = null;
656 foreach ( $apps as $app ) {
657 $used = isset( $app['last_used'] ) ? (int) $app['last_used'] : 0;
658 if ( $used > $best_used_ts ) {
659 $best_used_ts = $used;
660 $best_used_name = isset( $app['name'] ) ? (string) $app['name'] : null;
661 }
662 }
663 if ( $best_used_ts > 0 ) {
664 $app_passwords_summary['lastUsedAt'] = $best_used_ts;
665 $app_passwords_summary['lastUsedName'] = $best_used_name;
666 }
667 }
668
669 // ── Misc / temporal stats.
670 $registered_ts = strtotime( (string) $user->user_registered . ' UTC' );
671 $days_since_registration = $registered_ts
672 ? max( 0, (int) floor( ( time() - $registered_ts ) / DAY_IN_SECONDS ) )
673 : null;
674 $last_login_ts = (int) get_user_meta(
675 $id,
676 defined( 'DESKTOP_MODE_LAST_LOGIN_META_KEY' )
677 ? DESKTOP_MODE_LAST_LOGIN_META_KEY
678 : '_desktop_mode_last_login_at',
679 true
680 );
681 $last_login_ts = $last_login_ts > 0 ? $last_login_ts : null;
682 $days_since_last_login = $last_login_ts
683 ? max( 0, (int) floor( ( time() - $last_login_ts ) / DAY_IN_SECONDS ) )
684 : null;
685
686 // ── Roles + capabilities count for the profile chip strip.
687 $roles = array_values( (array) $user->roles );
688 $caps_count = is_array( $user->allcaps ) ? count(
689 array_filter( $user->allcaps, static function ( $v ) {
690 return (bool) $v;
691 } )
692 ) : 0;
693
694 return array(
695 'userId' => $id,
696 'displayName' => (string) $user->display_name,
697 'avatarUrl' => (string) get_avatar_url( $id, array( 'size' => 96 ) ),
698 'profileUrl' => (string) get_author_posts_url( $id ),
699 'roles' => $roles,
700 'capabilitiesCount' => $caps_count,
701 'profileCompleteness' => array(
702 'filled' => $filled,
703 'total' => $total,
704 'percent' => $percent,
705 ),
706 'stats' => array(
707 'posts' => $post_count,
708 'pages' => $page_count,
709 'attachments' => $attachment_count,
710 'commentsAuthored' => $comment_count,
711 'commentsReceived' => $received_comments,
712 'daysSinceRegistration' => $days_since_registration,
713 'lastLoginAt' => $last_login_ts,
714 'daysSinceLastLogin' => $days_since_last_login,
715 'registeredAt' => $registered_ts ?: null,
716 ),
717 'contentByMonth' => $content_by_month,
718 'recentPosts' => $recent_posts,
719 'recentComments' => $recent_comments,
720 'sessions' => $sessions,
721 'applicationPasswords' => $app_passwords_summary,
722 );
723 }
724