| 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 |
|