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

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