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

721 lines 23.4 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 function openstation_user_edit_window_rest_destroy_sessions( $req ) {
141 $id = (int) $req->get_param( 'id' );
142 $scope = (string) $req->get_param( 'scope' );
143 if ( ! class_exists( 'WP_Session_Tokens' ) ) {
144 return new WP_Error(
145 'openstation_users_no_sessions',
146 __( 'Session manager unavailable.', 'desktop-mode' ),
147 array( 'status' => 500 )
148 );
149 }
150 $manager = WP_Session_Tokens::get_instance( $id );
151 if ( 'all' === $scope || (int) get_current_user_id() !== $id ) {
152 // Editing another user — destroy ALL of their sessions.
153 // Editing self with scope='all' — destroy all (including
154 // the current). Note the latter logs the requester out.
155 $manager->destroy_all();
156 } else {
157 $manager->destroy_others( wp_get_session_token() );
158 }
159 // Bust the insights cache so the sessions count refreshes.
160 delete_transient( 'dm_user_insights_' . $id );
161 return rest_ensure_response( array( 'ok' => true ) );
162 }
163
164 /**
165 * `GET /users/<id>/application-passwords` — list app passwords.
166 * `POST /users/<id>/application-passwords` — create new.
167 * `DELETE /users/<id>/application-passwords/<uuid>` — revoke one.
168 *
169 * Thin wrappers over `WP_Application_Passwords` so the form has a
170 * single REST surface to talk to.
171 */
172 function openstation_user_edit_window_app_passwords_routes() {
173 register_rest_route(
174 'desktop-mode/v1',
175 '/users/(?P<id>\d+)/application-passwords',
176 array(
177 array(
178 'methods' => WP_REST_Server::READABLE,
179 'callback' => 'openstation_user_edit_window_rest_app_pw_list',
180 'permission_callback' => static function ( $req ) {
181 return openstation_user_edit_window_can_edit(
182 (int) get_current_user_id(),
183 (int) $req->get_param( 'id' )
184 );
185 },
186 ),
187 array(
188 'methods' => WP_REST_Server::CREATABLE,
189 'callback' => 'openstation_user_edit_window_rest_app_pw_create',
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 'args' => array(
197 'name' => array(
198 'required' => true,
199 'type' => 'string',
200 ),
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' => 'openstation_user_edit_window_rest_app_pw_revoke',
211 'permission_callback' => static function ( $req ) {
212 return openstation_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', 'openstation_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 openstation_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 'openstation_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 openstation_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 = openstation_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 openstation_user_edit_window_rest_app_pw_create( $req ) {
263 if ( ! class_exists( 'WP_Application_Passwords' ) ) {
264 return new WP_Error(
265 'openstation_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 = openstation_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 'openstation_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 openstation_user_edit_window_rest_app_pw_revoke( $req ) {
299 if ( ! class_exists( 'WP_Application_Passwords' ) ) {
300 return new WP_Error(
301 'openstation_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 = openstation_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 * @param WP_REST_Request $req
324 * @return WP_REST_Response|WP_Error
325 */
326 function openstation_user_edit_window_rest_insights( $req ) {
327 $id = (int) $req->get_param( 'id' );
328 $user = $id > 0 ? get_userdata( $id ) : null;
329 if ( ! $user instanceof WP_User ) {
330 return new WP_Error(
331 'openstation_users_not_found',
332 __( 'User not found.', 'desktop-mode' ),
333 array( 'status' => 404 )
334 );
335 }
336
337 $fresh = (bool) $req->get_param( 'fresh' );
338 $cache_key = 'dm_user_insights_' . $id;
339 $payload = null;
340 if ( ! $fresh ) {
341 $cached = get_transient( $cache_key );
342 if ( is_array( $cached ) ) {
343 $payload = $cached;
344 }
345 }
346 if ( null === $payload ) {
347 $payload = openstation_user_edit_window_compute_insights( $user );
348 }
349
350 // Self-view override — the viewer is by definition logged in
351 // right now since they're staring at their own profile. If the
352 // `_desktop_mode_last_login_at` meta isn't populated yet
353 // (account predates the plugin install, or the wp_login hook
354 // fires after the first profile open on a single-tick login)
355 // the tile would read "Never" — contradicting the obvious. Pin
356 // to current time and lazily backfill the meta so future reads
357 // no longer depend on this override. Lives OUTSIDE
358 // `compute_insights` so it applies on cache hits AS WELL AS
359 // fresh computes.
360 $viewer_id = (int) get_current_user_id();
361 if ( $id === $viewer_id ) {
362 $now = time();
363 $stored = (int) get_user_meta(
364 $id,
365 defined( 'OPENSTATION_LAST_LOGIN_META_KEY' )
366 ? OPENSTATION_LAST_LOGIN_META_KEY
367 : '_desktop_mode_last_login_at',
368 true
369 );
370 if ( $stored <= 0 ) {
371 update_user_meta(
372 $id,
373 defined( 'OPENSTATION_LAST_LOGIN_META_KEY' )
374 ? OPENSTATION_LAST_LOGIN_META_KEY
375 : '_desktop_mode_last_login_at',
376 $now
377 );
378 $stored = $now;
379 }
380 // Always reflect the truth on the payload — cached payloads
381 // from before the meta-backfill would otherwise still carry
382 // the stale `null`.
383 if ( ! isset( $payload['stats'] ) || ! is_array( $payload['stats'] ) ) {
384 $payload['stats'] = array();
385 }
386 if ( empty( $payload['stats']['lastLoginAt'] ) ) {
387 $payload['stats']['lastLoginAt'] = $stored;
388 $payload['stats']['daysSinceLastLogin'] = max(
389 0,
390 (int) floor( ( $now - $stored ) / DAY_IN_SECONDS )
391 );
392 }
393 }
394
395 /**
396 * Filter the insights payload before it's returned and cached.
397 *
398 * Plugins can append their own metrics (security-event counts,
399 * subscription tier, last-orders-placed, …) by extending the
400 * `stats` map or adding new top-level keys. The JS bundle
401 * tolerates unknown keys — they're surfaced as plugin tiles
402 * when they match the expected shape.
403 *
404 * @param array $payload Insights payload.
405 * @param WP_User $user Target user.
406 */
407 $payload = (array) apply_filters( 'openstation_user_edit_window_insights', $payload, $user );
408
409 set_transient( $cache_key, $payload, MINUTE_IN_SECONDS );
410
411 return rest_ensure_response( $payload );
412 }
413
414 /**
415 * Compute the insights payload for a user. Centralized so plugins
416 * can call it directly from a custom REST route or admin notice
417 * without going through the HTTP cycle.
418 *
419 * @param WP_User $user
420 * @return array
421 */
422 function openstation_user_edit_window_compute_insights( WP_User $user ) {
423 $id = (int) $user->ID;
424
425 // ── Profile completeness — count which core fields are non-empty.
426 $completeness_fields = array(
427 'first_name' => (string) $user->first_name,
428 'last_name' => (string) $user->last_name,
429 'nickname' => (string) $user->nickname,
430 'description' => (string) $user->description,
431 'user_url' => (string) $user->user_url,
432 'user_email' => (string) $user->user_email,
433 );
434 $filled = 0;
435 foreach ( $completeness_fields as $value ) {
436 if ( '' !== trim( $value ) ) {
437 ++$filled;
438 }
439 }
440 $total = count( $completeness_fields );
441 $percent = $total > 0 ? (int) round( ( $filled / $total ) * 100 ) : 0;
442
443 // ── Per-CPT post counts. `count_user_posts` does the cheap thing.
444 $post_count = (int) count_user_posts( $id, 'post', true );
445 $page_count = post_type_exists( 'page' )
446 ? (int) count_user_posts( $id, 'page', true )
447 : 0;
448 $attachment_count = (int) count_user_posts( $id, 'attachment', true );
449
450 // ── Comments authored by this user (not received).
451 $comment_count = (int) get_comments(
452 array(
453 'user_id' => $id,
454 'count' => true,
455 )
456 );
457
458 // ── Approved comments RECEIVED on this user's published posts.
459 // Cheap aggregate — one COUNT, no row hydration.
460 global $wpdb;
461 $received_comments = (int) $wpdb->get_var(
462 $wpdb->prepare(
463 "SELECT COUNT(c.comment_ID)
464 FROM {$wpdb->comments} c
465 INNER JOIN {$wpdb->posts} p
466 ON p.ID = c.comment_post_ID
467 WHERE p.post_author = %d
468 AND p.post_status = 'publish'
469 AND c.comment_approved = '1'",
470 $id
471 )
472 );
473
474 // ── Months for the activity sparkline. Bucket published posts
475 // by year-month for the last 12 months. SQL bucket → align in PHP.
476 $month_buckets = array();
477 $now = time();
478 for ( $i = 11; $i >= 0; $i-- ) {
479 $ts = strtotime( "-{$i} months", $now );
480 $key = gmdate( 'Y-m', $ts );
481 $month_buckets[ $key ] = 0;
482 }
483 $rows = (array) $wpdb->get_results(
484 $wpdb->prepare(
485 "SELECT DATE_FORMAT( post_date_gmt, '%%Y-%%m' ) AS bucket,
486 COUNT(*) AS cnt
487 FROM {$wpdb->posts}
488 WHERE post_author = %d
489 AND post_status IN ( 'publish', 'private', 'future' )
490 AND post_date_gmt >= %s
491 GROUP BY bucket
492 ORDER BY bucket ASC",
493 $id,
494 gmdate( 'Y-m-01 00:00:00', strtotime( '-12 months', $now ) )
495 ),
496 ARRAY_A
497 );
498 foreach ( $rows as $row ) {
499 $bucket = isset( $row['bucket'] ) ? (string) $row['bucket'] : '';
500 if ( isset( $month_buckets[ $bucket ] ) ) {
501 $month_buckets[ $bucket ] = (int) $row['cnt'];
502 }
503 }
504 $content_by_month = array();
505 foreach ( $month_buckets as $bucket => $cnt ) {
506 $content_by_month[] = array(
507 'month' => $bucket,
508 'count' => $cnt,
509 );
510 }
511
512 // ── Recent posts (any status). Limit 5.
513 $recent_posts = array();
514 $recent = get_posts(
515 array(
516 'author' => $id,
517 'post_type' => 'any',
518 'post_status' => array(
519 'publish',
520 'draft',
521 'pending',
522 'future',
523 'private',
524 ),
525 'posts_per_page' => 5,
526 'orderby' => 'date',
527 'order' => 'DESC',
528 )
529 );
530 foreach ( $recent as $post ) {
531 // `post_date_gmt` is `'0000-00-00 00:00:00'` for drafts that
532 // have never been published — the JS Date.parse of that
533 // returns NaN, and the previous fallback rendered every
534 // draft's "recent activity" timestamp as "just now". Use
535 // `get_gmt_from_date( post_date )` to convert the always-set
536 // local `post_date` to UTC when the GMT field is zero.
537 $gmt = (string) $post->post_date_gmt;
538 if ( '' === $gmt || 0 === strpos( $gmt, '0000-00-00' ) ) {
539 $gmt = (string) get_gmt_from_date( (string) $post->post_date );
540 }
541 $recent_posts[] = array(
542 'id' => (int) $post->ID,
543 'title' => '' !== $post->post_title
544 ? $post->post_title
545 : __( '(no title)', 'desktop-mode' ),
546 'status' => (string) $post->post_status,
547 'type' => (string) $post->post_type,
548 'dateGmt' => $gmt,
549 'commentCount' => (int) $post->comment_count,
550 'permalink' => (string) get_permalink( $post ),
551 'editUrl' => (string) get_edit_post_link( $post->ID, 'raw' ),
552 );
553 }
554
555 // ── Recent comments authored by this user. Limit 5.
556 $recent_comments = array();
557 $comments = get_comments(
558 array(
559 'user_id' => $id,
560 'number' => 5,
561 'orderby' => 'comment_date_gmt',
562 'order' => 'DESC',
563 )
564 );
565 foreach ( (array) $comments as $comment ) {
566 $post_title = '';
567 if ( $comment->comment_post_ID ) {
568 $post = get_post( (int) $comment->comment_post_ID );
569 if ( $post instanceof WP_Post ) {
570 $post_title = '' !== $post->post_title
571 ? $post->post_title
572 : __( '(no title)', 'desktop-mode' );
573 }
574 }
575 // Same zero-date fallback as recent posts above.
576 $comment_gmt = (string) $comment->comment_date_gmt;
577 if ( '' === $comment_gmt || 0 === strpos( $comment_gmt, '0000-00-00' ) ) {
578 $comment_gmt = (string) get_gmt_from_date(
579 (string) $comment->comment_date
580 );
581 }
582 $recent_comments[] = array(
583 'id' => (int) $comment->comment_ID,
584 'postId' => (int) $comment->comment_post_ID,
585 'postTitle' => $post_title,
586 'excerpt' => wp_trim_words(
587 wp_strip_all_tags( (string) $comment->comment_content ),
588 24
589 ),
590 'dateGmt' => $comment_gmt,
591 'approved' => '1' === (string) $comment->comment_approved,
592 );
593 }
594
595 // ── Active sessions (`WP_Session_Tokens`). The token bag is a
596 // blob of metadata per device — UA / IP / login + expiration. We
597 // surface a per-session row plus a current-session flag.
598 $sessions = array();
599 if ( class_exists( 'WP_Session_Tokens' ) ) {
600 $manager = WP_Session_Tokens::get_instance( $id );
601 $current_token = wp_get_session_token();
602 // The meta blob's keys are *verifiers* — hashes of the raw
603 // cookie token (`WP_Session_Tokens::hash_token()`), so hash
604 // the current token the same way before comparing.
605 $current_verifier = '';
606 if ( $current_token ) {
607 $current_verifier = function_exists( 'hash' )
608 ? hash( 'sha256', $current_token )
609 : sha1( $current_token );
610 }
611 // `get_all` returns the tokens-as-array but doesn't expose
612 // the token id — peek into the meta blob via the user meta
613 // key directly so we can flag the "current" session.
614 $raw_tokens = (array) get_user_meta( $id, 'session_tokens', true );
615 // Prune expired entries the same way `WP_Session_Tokens`
616 // does on its own write path (so a user who hasn't logged
617 // in for a while doesn't show stale device rows).
618 $now_ts = time();
619 foreach ( $raw_tokens as $hash => $info ) {
620 if ( ! is_array( $info ) ) {
621 continue;
622 }
623 $expires = isset( $info['expiration'] ) ? (int) $info['expiration'] : 0;
624 if ( $expires > 0 && $expires < $now_ts ) {
625 continue;
626 }
627 $sessions[] = array(
628 'expiration' => $expires,
629 'login' => isset( $info['login'] ) ? (int) $info['login'] : 0,
630 'ip' => isset( $info['ip'] ) ? (string) $info['ip'] : '',
631 'ua' => isset( $info['ua'] ) ? (string) $info['ua'] : '',
632 'current' => '' !== $current_verifier && $current_verifier === $hash,
633 );
634 }
635 unset( $manager ); // unused but instantiated for symmetry / future use.
636 }
637
638 // ── Application passwords (WordPress 5.6+). Stored as user meta.
639 $app_passwords_summary = array(
640 'total' => 0,
641 'lastUsedAt' => null,
642 'lastUsedName' => null,
643 );
644 if ( class_exists( 'WP_Application_Passwords' ) ) {
645 $apps = WP_Application_Passwords::get_user_application_passwords( $id );
646 $apps = is_array( $apps ) ? $apps : array();
647 $app_passwords_summary['total'] = count( $apps );
648 $best_used_ts = 0;
649 $best_used_name = null;
650 foreach ( $apps as $app ) {
651 $used = isset( $app['last_used'] ) ? (int) $app['last_used'] : 0;
652 if ( $used > $best_used_ts ) {
653 $best_used_ts = $used;
654 $best_used_name = isset( $app['name'] ) ? (string) $app['name'] : null;
655 }
656 }
657 if ( $best_used_ts > 0 ) {
658 $app_passwords_summary['lastUsedAt'] = $best_used_ts;
659 $app_passwords_summary['lastUsedName'] = $best_used_name;
660 }
661 }
662
663 // ── Misc / temporal stats.
664 $registered_ts = strtotime( (string) $user->user_registered . ' UTC' );
665 $days_since_registration = $registered_ts
666 ? max( 0, (int) floor( ( time() - $registered_ts ) / DAY_IN_SECONDS ) )
667 : null;
668 $last_login_ts = (int) get_user_meta(
669 $id,
670 defined( 'OPENSTATION_LAST_LOGIN_META_KEY' )
671 ? OPENSTATION_LAST_LOGIN_META_KEY
672 : '_desktop_mode_last_login_at',
673 true
674 );
675 $last_login_ts = $last_login_ts > 0 ? $last_login_ts : null;
676 $days_since_last_login = $last_login_ts
677 ? max( 0, (int) floor( ( time() - $last_login_ts ) / DAY_IN_SECONDS ) )
678 : null;
679
680 // ── Roles + capabilities count for the profile chip strip.
681 $roles = array_values( (array) $user->roles );
682 $caps_count = is_array( $user->allcaps ) ? count(
683 array_filter(
684 $user->allcaps,
685 static function ( $v ) {
686 return (bool) $v;
687 }
688 )
689 ) : 0;
690
691 return array(
692 'userId' => $id,
693 'displayName' => (string) $user->display_name,
694 'avatarUrl' => (string) get_avatar_url( $id, array( 'size' => 96 ) ),
695 'profileUrl' => (string) get_author_posts_url( $id ),
696 'roles' => $roles,
697 'capabilitiesCount' => $caps_count,
698 'profileCompleteness' => array(
699 'filled' => $filled,
700 'total' => $total,
701 'percent' => $percent,
702 ),
703 'stats' => array(
704 'posts' => $post_count,
705 'pages' => $page_count,
706 'attachments' => $attachment_count,
707 'commentsAuthored' => $comment_count,
708 'commentsReceived' => $received_comments,
709 'daysSinceRegistration' => $days_since_registration,
710 'lastLoginAt' => $last_login_ts,
711 'daysSinceLastLogin' => $days_since_last_login,
712 'registeredAt' => $registered_ts ? $registered_ts : null,
713 ),
714 'contentByMonth' => $content_by_month,
715 'recentPosts' => $recent_posts,
716 'recentComments' => $recent_comments,
717 'sessions' => $sessions,
718 'applicationPasswords' => $app_passwords_summary,
719 );
720 }
721