PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / my-wordpress / user-stats.php

user-stats.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.10, at includes/my-wordpress/user-stats.php

491 lines 16.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — My WordPress: per-user stats endpoint.
4 *
5 * `GET /desktop-mode/v1/user-stats/<id>` returns an aggregated
6 * profile + activity blob for the requested user. The right
7 * preview pane in the My WordPress folder uses it to paint a rich
8 * dossier (post / page / comment counts, recent posts, top
9 * categories, role + member-since) without forcing the client to
10 * make N parallel REST calls.
11 *
12 * Permissions: the My WordPress module's gate,
13 * `openstation_my_wordpress_user_can_use()` (`edit_posts` unless a site
14 * filters it), so a site that narrows WP Explorer narrows this data
15 * with it. Past that gate, anyone with `list_users` (or the subject
16 * user viewing their own dossier) sees full data; everyone else sees
17 * the public subset (display name, avatar, post archive link,
18 * published-only counts and recent posts). Sensitive fields (email,
19 * registered date, role) are gated on the cap.
20 *
21 * For the unprivileged subset, `publish` alone is not the test for
22 * the counts that reach beyond the subject's own posts and pages
23 * (`cpt`, `commentsReceived`, `commentsLeft`): a type with no readable
24 * front end holds `publish` rows a visitor could never open, so those
25 * counts ask `is_post_type_viewable()` as well. The comment counts also
26 * skip password-protected and deleted parents, and ask the comment
27 * dossier's gate of every parent they count, so a plugin that filters
28 * `read_post` for a single published post takes its comments out of
29 * them. For every viewer, `cpt` leaves out the post types Core
30 * registers (`_builtin`). The payload is viewer-dependent: never cache
31 * it under a subject-only key.
32 *
33 * @package OpenStation
34 */
35
36 defined( 'ABSPATH' ) || exit;
37
38 /**
39 * Register the route.
40 */
41 function openstation_my_wordpress_register_user_stats_route() {
42 register_rest_route(
43 'desktop-mode/v1',
44 '/user-stats/(?P<id>\d+)',
45 array(
46 'methods' => WP_REST_Server::READABLE,
47 'callback' => 'openstation_my_wordpress_user_stats_callback',
48 'permission_callback' => static function () {
49 // The module's gate, so a site that narrows WP Explorer
50 // narrows this data with it. The per-viewer scoping lives
51 // in the callback, which in-process callers invoke directly.
52 return openstation_my_wordpress_user_can_use();
53 },
54 'args' => array(
55 'id' => array(
56 'required' => true,
57 'type' => 'integer',
58 'sanitize_callback' => 'absint',
59 ),
60 ),
61 )
62 );
63 }
64 add_action( 'rest_api_init', 'openstation_my_wordpress_register_user_stats_route' );
65
66 /**
67 * Sum per-parent comment counts over the parents the viewer may read.
68 *
69 * The query behind the rows has already kept only published, unsealed
70 * parents of a viewable type. That settles the parent's status, type
71 * and password, but not the post itself: `read_post` is filterable per
72 * post, and the comment dossier asks it of a published parent too, so a
73 * plugin can withhold one post and `/comment-stats` then refuses its
74 * comments. Every parent goes through that same gate,
75 * openstation_my_wordpress_can_read_comment_post(), so a count never
76 * reports comments the dossier withholds. The parents are loaded in one
77 * query, and each is decided once per request.
78 *
79 * @param array[]|null $rows Rows carrying the parent's `post_id` and its comment count `n`.
80 * @param bool[] $verdicts Gate answers already reached in this request, keyed by post id.
81 * @return int
82 */
83 function openstation_my_wordpress_user_stats_readable_comment_count( $rows, array &$verdicts ) {
84 $rows = (array) $rows;
85 $unseen = array();
86 foreach ( $rows as $row ) {
87 $id = (int) $row['post_id'];
88 if ( $id > 0 && ! isset( $verdicts[ $id ] ) ) {
89 $unseen[ $id ] = $id;
90 }
91 }
92 if ( $unseen ) {
93 _prime_post_caches( array_values( $unseen ), false, false );
94 }
95
96 $total = 0;
97 foreach ( $rows as $row ) {
98 $id = (int) $row['post_id'];
99 if ( ! isset( $verdicts[ $id ] ) ) {
100 $verdicts[ $id ] = openstation_my_wordpress_can_read_comment_post( $id > 0 ? get_post( $id ) : null );
101 }
102 if ( $verdicts[ $id ] ) {
103 $total += (int) $row['n'];
104 }
105 }
106 return $total;
107 }
108
109 /**
110 * Aggregator callback. Returns the dossier shape (see file
111 * docblock above for fields).
112 *
113 * @param WP_REST_Request $request REST request.
114 * @return array|WP_Error
115 */
116 function openstation_my_wordpress_user_stats_callback( $request ) {
117 global $wpdb;
118 $user_id = (int) $request->get_param( 'id' );
119 $user = get_userdata( $user_id );
120 if ( ! $user ) {
121 return new WP_Error(
122 'openstation_user_not_found',
123 __( 'User not found.', 'desktop-mode' ),
124 array( 'status' => 404 )
125 );
126 }
127
128 $can_see_private = current_user_can( 'list_users' )
129 || ( get_current_user_id() === $user_id );
130
131 // ----- Profile -----------------------------------------------------
132 $profile = array(
133 'id' => (int) $user->ID,
134 'name' => $user->display_name,
135 'description' => (string) $user->description,
136 'link' => get_author_posts_url( $user->ID ),
137 'website' => esc_url_raw( $user->user_url ),
138 'avatarUrl' => get_avatar_url( $user->ID, array( 'size' => 192 ) ),
139 );
140 if ( $can_see_private ) {
141 $profile['email'] = $user->user_email;
142 $profile['username'] = $user->user_login;
143 $profile['registered'] = mysql2date( 'c', $user->user_registered, false );
144 $profile['roles'] = array_values( (array) $user->roles );
145 $role_labels = array();
146 if ( function_exists( 'wp_roles' ) ) {
147 $wp_roles = wp_roles();
148 foreach ( (array) $user->roles as $slug ) {
149 $role_labels[] = isset( $wp_roles->role_names[ $slug ] )
150 ? translate_user_role( $wp_roles->role_names[ $slug ] )
151 : $slug;
152 }
153 }
154 $profile['roleLabels'] = $role_labels;
155 }
156
157 // ----- Counts ------------------------------------------------------
158 // Posts (the post type) by status.
159 $post_status_rows = $wpdb->get_results(
160 $wpdb->prepare(
161 "SELECT post_status, COUNT(*) AS n
162 FROM {$wpdb->posts}
163 WHERE post_author = %d
164 AND post_type = 'post'
165 AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
166 GROUP BY post_status",
167 $user_id
168 ),
169 ARRAY_A
170 );
171 $post_counts = array(
172 'publish' => 0,
173 'draft' => 0,
174 'pending' => 0,
175 'private' => 0,
176 'future' => 0,
177 'total' => 0,
178 );
179 foreach ( (array) $post_status_rows as $row ) {
180 $status = (string) $row['post_status'];
181 $n = (int) $row['n'];
182 $post_counts['total'] += $n;
183 if ( isset( $post_counts[ $status ] ) ) {
184 $post_counts[ $status ] = $n;
185 }
186 }
187
188 // Pages by status.
189 $page_status_rows = $wpdb->get_results(
190 $wpdb->prepare(
191 "SELECT post_status, COUNT(*) AS n
192 FROM {$wpdb->posts}
193 WHERE post_author = %d
194 AND post_type = 'page'
195 AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
196 GROUP BY post_status",
197 $user_id
198 ),
199 ARRAY_A
200 );
201 $page_counts = array(
202 'publish' => 0,
203 'draft' => 0,
204 'total' => 0,
205 );
206 foreach ( (array) $page_status_rows as $row ) {
207 $status = (string) $row['post_status'];
208 $n = (int) $row['n'];
209 $page_counts['total'] += $n;
210 if ( isset( $page_counts[ $status ] ) ) {
211 $page_counts[ $status ] = $n;
212 }
213 }
214
215 if ( ! $can_see_private ) {
216 // Viewers without `list_users` (and who aren't the subject)
217 // only get published counts — see the permissions note in
218 // the file docblock.
219 $post_counts = array(
220 'publish' => $post_counts['publish'],
221 'total' => $post_counts['publish'],
222 );
223 $page_counts = array(
224 'publish' => $page_counts['publish'],
225 'total' => $page_counts['publish'],
226 );
227 }
228
229 // ----- Counts beyond the subject's own posts and pages -------------
230 // For a viewer without `list_users`, each count below takes two gates,
231 // because `publish` is not visibility on its own: the row has to be
232 // published, AND its post type has to be one a visitor could actually
233 // open. A plugin's internal type (an order, a submission log, an
234 // internal note) registers rows with a `publish` status and no front
235 // end at all, so the type list comes from `is_post_type_viewable()`,
236 // the question the comment tools and the term-stats endpoint settled
237 // on. A comment count also skips a password-protected parent, whose
238 // comments are sealed along with it, and a parent that no longer
239 // exists. Those three settle the parent's status, type and password,
240 // but not the post itself: `read_post` is filterable per post, so each
241 // comment count also asks the comment dossier's gate of every parent
242 // it counts, through
243 // openstation_my_wordpress_user_stats_readable_comment_count().
244 // Privileged viewers keep every count whole.
245 $viewable_types = array_values( array_filter( get_post_types(), 'is_post_type_viewable' ) );
246 $viewable_list = implode( ', ', array_fill( 0, count( $viewable_types ), '%s' ) );
247
248 // Gate answers per parent post, shared by both comment counts.
249 $comment_verdicts = array();
250
251 // Comments received on posts authored by this user, approved only.
252 if ( $can_see_private ) {
253 $comments_received = (int) $wpdb->get_var(
254 $wpdb->prepare(
255 "SELECT COUNT(c.comment_ID)
256 FROM {$wpdb->comments} c
257 INNER JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
258 WHERE p.post_author = %d
259 AND c.comment_approved = '1'
260 AND p.post_status NOT IN ( 'auto-draft', 'trash' )",
261 $user_id
262 )
263 );
264 } elseif ( $viewable_types ) {
265 $received_rows = $wpdb->get_results(
266 $wpdb->prepare(
267 "SELECT p.ID AS post_id, COUNT(c.comment_ID) AS n
268 FROM {$wpdb->comments} c
269 INNER JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
270 WHERE p.post_author = %d
271 AND c.comment_approved = '1'
272 AND p.post_status = 'publish'
273 AND p.post_password = ''
274 AND p.post_type IN ( {$viewable_list} )
275 GROUP BY p.ID",
276 array_merge( array( $user_id ), $viewable_types )
277 ),
278 ARRAY_A
279 );
280 $comments_received = openstation_my_wordpress_user_stats_readable_comment_count( $received_rows, $comment_verdicts );
281 } else {
282 $comments_received = 0;
283 }
284
285 // Comments left BY this user (regardless of post author).
286 if ( $can_see_private ) {
287 $comments_left = (int) $wpdb->get_var(
288 $wpdb->prepare(
289 "SELECT COUNT(*)
290 FROM {$wpdb->comments}
291 WHERE user_id = %d
292 AND comment_approved = '1'",
293 $user_id
294 )
295 );
296 } elseif ( $viewable_types ) {
297 $left_rows = $wpdb->get_results(
298 $wpdb->prepare(
299 "SELECT p.ID AS post_id, COUNT(c.comment_ID) AS n
300 FROM {$wpdb->comments} c
301 INNER JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
302 WHERE c.user_id = %d
303 AND c.comment_approved = '1'
304 AND p.post_status = 'publish'
305 AND p.post_password = ''
306 AND p.post_type IN ( {$viewable_list} )
307 GROUP BY p.ID",
308 array_merge( array( $user_id ), $viewable_types )
309 ),
310 ARRAY_A
311 );
312 $comments_left = openstation_my_wordpress_user_stats_readable_comment_count( $left_rows, $comment_verdicts );
313 } else {
314 $comments_left = 0;
315 }
316
317 // Total content in custom post types. Every type Core registers is
318 // left out (`_builtin`): posts and pages because they are counted
319 // above, and the rest (attachments, revisions, menu items, synced
320 // patterns, templates, navigation menus, global styles, changesets,
321 // oEmbed caches, ...) because none of it is a custom post type. An
322 // exclusion list naming a handful of them counted every one it did
323 // not name.
324 $builtin_types = array_values( get_post_types( array( '_builtin' => true ) ) );
325 if ( $can_see_private ) {
326 $builtin_list = implode( ', ', array_fill( 0, count( $builtin_types ), '%s' ) );
327 $cpt_count = (int) $wpdb->get_var(
328 $wpdb->prepare(
329 "SELECT COUNT(*)
330 FROM {$wpdb->posts}
331 WHERE post_author = %d
332 AND post_type NOT IN ( {$builtin_list} )
333 AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )",
334 array_merge( array( $user_id ), $builtin_types )
335 )
336 );
337 } else {
338 $cpt_types = array_values( array_diff( $viewable_types, $builtin_types ) );
339 if ( $cpt_types ) {
340 $cpt_list = implode( ', ', array_fill( 0, count( $cpt_types ), '%s' ) );
341 $cpt_count = (int) $wpdb->get_var(
342 $wpdb->prepare(
343 "SELECT COUNT(*)
344 FROM {$wpdb->posts}
345 WHERE post_author = %d
346 AND post_type IN ( {$cpt_list} )
347 AND post_status = 'publish'",
348 array_merge( array( $user_id ), $cpt_types )
349 )
350 );
351 } else {
352 $cpt_count = 0;
353 }
354 }
355
356 $counts = array(
357 'posts' => $post_counts,
358 'pages' => $page_counts,
359 'commentsReceived' => $comments_received,
360 'commentsLeft' => $comments_left,
361 'cpt' => $cpt_count,
362 );
363
364 // ----- Recent posts (latest 5) -------------------------------------
365 // Privileged viewers also see private/scheduled/draft/pending;
366 // everyone else gets published posts only.
367 $recent_posts = get_posts(
368 array(
369 'author' => $user_id,
370 'post_type' => array( 'post', 'page' ),
371 'post_status' => $can_see_private
372 ? array( 'publish', 'private', 'future', 'draft', 'pending' )
373 : array( 'publish' ),
374 'posts_per_page' => 5,
375 'orderby' => 'date',
376 'order' => 'DESC',
377 'suppress_filters' => false,
378 )
379 );
380 $recent = array();
381 foreach ( (array) $recent_posts as $p ) {
382 if ( ! ( $p instanceof WP_Post ) ) {
383 continue;
384 }
385 $recent[] = array(
386 'id' => (int) $p->ID,
387 'title' => get_the_title( $p ),
388 'date' => mysql2date( 'c', $p->post_date_gmt, false ),
389 'status' => (string) $p->post_status,
390 'type' => (string) $p->post_type,
391 'link' => get_permalink( $p ),
392 );
393 }
394
395 // ----- Top categories (most used by this author) -------------------
396 $top_term_rows = $wpdb->get_results(
397 $wpdb->prepare(
398 "SELECT t.term_id, t.name, t.slug, tt.taxonomy, COUNT(*) AS n
399 FROM {$wpdb->term_relationships} tr
400 INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
401 INNER JOIN {$wpdb->terms} t ON tt.term_id = t.term_id
402 INNER JOIN {$wpdb->posts} p ON tr.object_id = p.ID
403 WHERE p.post_author = %d
404 AND p.post_type = 'post'
405 AND p.post_status = 'publish'
406 AND tt.taxonomy IN ( 'category', 'post_tag' )
407 GROUP BY t.term_id, tt.taxonomy
408 ORDER BY n DESC
409 LIMIT 5",
410 $user_id
411 ),
412 ARRAY_A
413 );
414 $top_terms = array();
415 foreach ( (array) $top_term_rows as $row ) {
416 $top_terms[] = array(
417 'id' => (int) $row['term_id'],
418 'name' => (string) $row['name'],
419 'slug' => (string) $row['slug'],
420 'taxonomy' => (string) $row['taxonomy'],
421 'count' => (int) $row['n'],
422 );
423 }
424
425 // ----- Activity (posts published per month, last 12 months) --------
426 // Lightweight sparkline source. Exclude trash + auto-draft, group by
427 // year-month so the JS can fill gaps.
428 $activity_rows = $wpdb->get_results(
429 $wpdb->prepare(
430 "SELECT DATE_FORMAT( post_date_gmt, '%%Y-%%m' ) AS ym, COUNT(*) AS n
431 FROM {$wpdb->posts}
432 WHERE post_author = %d
433 AND post_type IN ( 'post', 'page' )
434 AND post_status = 'publish'
435 AND post_date_gmt >= DATE_SUB( NOW(), INTERVAL 12 MONTH )
436 GROUP BY ym
437 ORDER BY ym ASC",
438 $user_id
439 ),
440 ARRAY_A
441 );
442 $activity = array();
443 foreach ( (array) $activity_rows as $row ) {
444 $activity[] = array(
445 'ym' => (string) $row['ym'],
446 'count' => (int) $row['n'],
447 );
448 }
449
450 // ----- First & last published --------------------------------------
451 // (Streak math lives in the separate user-footprint endpoint.)
452 $first_post = $wpdb->get_var(
453 $wpdb->prepare(
454 "SELECT MIN(post_date_gmt) FROM {$wpdb->posts}
455 WHERE post_author = %d AND post_type IN ( 'post', 'page' ) AND post_status = 'publish'",
456 $user_id
457 )
458 );
459 $last_post = $wpdb->get_var(
460 $wpdb->prepare(
461 "SELECT MAX(post_date_gmt) FROM {$wpdb->posts}
462 WHERE post_author = %d AND post_type IN ( 'post', 'page' ) AND post_status = 'publish'",
463 $user_id
464 )
465 );
466 $milestones = array(
467 'firstPublished' => $first_post ? mysql2date( 'c', $first_post, false ) : null,
468 'lastPublished' => $last_post ? mysql2date( 'c', $last_post, false ) : null,
469 );
470
471 $payload = array(
472 'profile' => $profile,
473 'counts' => $counts,
474 'recent' => $recent,
475 'topTerms' => $top_terms,
476 'activity' => $activity,
477 'milestones' => $milestones,
478 );
479
480 /**
481 * Filter the per-user stats payload before it's returned to
482 * the My WordPress folder window. Plugins can drop additional
483 * stat sections (badges, milestones, contribution streaks)
484 * here without forking the JS render.
485 *
486 * @param array $payload Stats payload.
487 * @param int $user_id Subject user id.
488 */
489 return apply_filters( 'openstation_my_wordpress_user_stats', $payload, $user_id );
490 }
491