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
← All changes | includes/my-wordpress/user-stats.php +208 -75 0.9.31.1.10 View file →
@@ -1,7 +1,7 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — My WordPress: per-user stats endpoint.
3 + * OpenStation — My WordPress: per-user stats endpoint.
4 4 *
5 5 * `GET /desktop-mode/v1/user-stats/<id>` returns an aggregated
6 6 * profile + activity blob for the requested user. The right
7 7 * preview pane in the My WordPress folder uses it to paint a rich
@@ -8,16 +8,30 @@
8 8 * dossier (post / page / comment counts, recent posts, top
9 9 * categories, role + member-since) without forcing the client to
10 10 * make N parallel REST calls.
11 11 *
12 - * Permissions: anyone with `list_users` — or the subject user
13 - * viewing their own dossier — sees full data; everyone else sees
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
14 17 * the public subset (display name, avatar, post archive link,
15 - * published-only counts and recent posts). Sensitive fields
16 - * (email, registered date, role) are gated on the cap.
18 + * published-only counts and recent posts). Sensitive fields (email,
19 + * registered date, role) are gated on the cap.
17 20 *
18 - * @package WPDesktopMode
19 - * @since 0.8.0
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
20 34 */
21 35
22 36 defined( 'ABSPATH' ) || exit;
23 37
@@ -22,22 +36,21 @@
22 36 defined( 'ABSPATH' ) || exit;
23 37
24 38 /**
25 39 * Register the route.
26 - *
27 - * @since 0.8.0
28 40 */
29 -function desktop_mode_my_wordpress_register_user_stats_route() {
41 +function openstation_my_wordpress_register_user_stats_route() {
30 42 register_rest_route(
31 43 'desktop-mode/v1',
32 44 '/user-stats/(?P<id>\d+)',
33 45 array(
34 46 'methods' => WP_REST_Server::READABLE,
35 - 'callback' => 'desktop_mode_my_wordpress_user_stats_callback',
47 + 'callback' => 'openstation_my_wordpress_user_stats_callback',
36 48 'permission_callback' => static function () {
37 - // Logged-in users only — author archives are public,
38 - // but the dossier mixes counts that aren't.
39 - return is_user_logged_in();
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();
40 53 },
41 54 'args' => array(
42 55 'id' => array(
43 56 'required' => true,
@@ -47,26 +60,67 @@
47 60 ),
48 61 )
49 62 );
50 63 }
51 -add_action( 'rest_api_init', 'desktop_mode_my_wordpress_register_user_stats_route' );
64 +add_action( 'rest_api_init', 'openstation_my_wordpress_register_user_stats_route' );
52 65
53 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 +/**
54 110 * Aggregator callback. Returns the dossier shape (see file
55 111 * docblock above for fields).
56 112 *
57 - * @since 0.8.0
58 - *
59 113 * @param WP_REST_Request $request REST request.
60 114 * @return array|WP_Error
61 115 */
62 -function desktop_mode_my_wordpress_user_stats_callback( $request ) {
116 +function openstation_my_wordpress_user_stats_callback( $request ) {
63 117 global $wpdb;
64 118 $user_id = (int) $request->get_param( 'id' );
65 119 $user = get_userdata( $user_id );
66 120 if ( ! $user ) {
67 121 return new WP_Error(
68 - 'desktop_mode_user_not_found',
122 + 'openstation_user_not_found',
69 123 __( 'User not found.', 'desktop-mode' ),
70 124 array( 'status' => 404 )
71 125 );
72 126 }
@@ -113,9 +167,9 @@
113 167 $user_id
114 168 ),
115 169 ARRAY_A
116 170 );
117 - $post_counts = array(
171 + $post_counts = array(
118 172 'publish' => 0,
119 173 'draft' => 0,
120 174 'pending' => 0,
121 175 'private' => 0,
@@ -122,11 +176,11 @@
122 176 'future' => 0,
123 177 'total' => 0,
124 178 );
125 179 foreach ( (array) $post_status_rows as $row ) {
126 - $status = (string) $row['post_status'];
127 - $n = (int) $row['n'];
128 - $post_counts['total'] += $n;
180 + $status = (string) $row['post_status'];
181 + $n = (int) $row['n'];
182 + $post_counts['total'] += $n;
129 183 if ( isset( $post_counts[ $status ] ) ) {
130 184 $post_counts[ $status ] = $n;
131 185 }
132 186 }
@@ -143,17 +197,17 @@
143 197 $user_id
144 198 ),
145 199 ARRAY_A
146 200 );
147 - $page_counts = array(
201 + $page_counts = array(
148 202 'publish' => 0,
149 203 'draft' => 0,
150 204 'total' => 0,
151 205 );
152 206 foreach ( (array) $page_status_rows as $row ) {
153 - $status = (string) $row['post_status'];
154 - $n = (int) $row['n'];
155 - $page_counts['total'] += $n;
207 + $status = (string) $row['post_status'];
208 + $n = (int) $row['n'];
209 + $page_counts['total'] += $n;
156 210 if ( isset( $page_counts[ $status ] ) ) {
157 211 $page_counts[ $status ] = $n;
158 212 }
159 213 }
@@ -171,53 +225,134 @@
171 225 'total' => $page_counts['publish'],
172 226 );
173 227 }
174 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 +
175 251 // Comments received on posts authored by this user, approved only.
176 - // Non-privileged viewers only see engagement on published content.
177 - $received_status_sql = $can_see_private
178 - ? "p.post_status NOT IN ( 'auto-draft', 'trash' )"
179 - : "p.post_status = 'publish'";
180 - $comments_received = (int) $wpdb->get_var(
181 - $wpdb->prepare(
182 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- literal status clause chosen above.
183 - "SELECT COUNT(c.comment_ID)
184 - FROM {$wpdb->comments} c
185 - INNER JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
186 - WHERE p.post_author = %d
187 - AND c.comment_approved = '1'
188 - AND {$received_status_sql}",
189 - $user_id
190 - )
191 - );
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 + }
192 284
193 285 // Comments left BY this user (regardless of post author).
194 - $comments_left = (int) $wpdb->get_var(
195 - $wpdb->prepare(
196 - "SELECT COUNT(*)
197 - FROM {$wpdb->comments}
198 - WHERE user_id = %d
199 - AND comment_approved = '1'",
200 - $user_id
201 - )
202 - );
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 + }
203 316
204 - // Total content (posts + pages + any custom public post types).
205 - // Same gating as above: published-only unless privileged.
206 - $cpt_status_sql = $can_see_private
207 - ? "post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )"
208 - : "post_status = 'publish'";
209 - $cpt_count = (int) $wpdb->get_var(
210 - $wpdb->prepare(
211 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- literal status clause chosen above.
212 - "SELECT COUNT(*)
213 - FROM {$wpdb->posts}
214 - WHERE post_author = %d
215 - AND post_type NOT IN ( 'post', 'page', 'attachment', 'revision', 'nav_menu_item' )
216 - AND {$cpt_status_sql}",
217 - $user_id
218 - )
219 - );
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 + }
220 355
221 356 $counts = array(
222 357 'posts' => $post_counts,
223 358 'pages' => $page_counts,
@@ -241,9 +376,9 @@
241 376 'order' => 'DESC',
242 377 'suppress_filters' => false,
243 378 )
244 379 );
245 - $recent = array();
380 + $recent = array();
246 381 foreach ( (array) $recent_posts as $p ) {
247 382 if ( ! ( $p instanceof WP_Post ) ) {
248 383 continue;
249 384 }
@@ -275,9 +410,9 @@
275 410 $user_id
276 411 ),
277 412 ARRAY_A
278 413 );
279 - $top_terms = array();
414 + $top_terms = array();
280 415 foreach ( (array) $top_term_rows as $row ) {
281 416 $top_terms[] = array(
282 417 'id' => (int) $row['term_id'],
283 418 'name' => (string) $row['name'],
@@ -303,9 +438,9 @@
303 438 $user_id
304 439 ),
305 440 ARRAY_A
306 441 );
307 - $activity = array();
442 + $activity = array();
308 443 foreach ( (array) $activity_rows as $row ) {
309 444 $activity[] = array(
310 445 'ym' => (string) $row['ym'],
311 446 'count' => (int) $row['n'],
@@ -320,9 +455,9 @@
320 455 WHERE post_author = %d AND post_type IN ( 'post', 'page' ) AND post_status = 'publish'",
321 456 $user_id
322 457 )
323 458 );
324 - $last_post = $wpdb->get_var(
459 + $last_post = $wpdb->get_var(
325 460 $wpdb->prepare(
326 461 "SELECT MAX(post_date_gmt) FROM {$wpdb->posts}
327 462 WHERE post_author = %d AND post_type IN ( 'post', 'page' ) AND post_status = 'publish'",
328 463 $user_id
@@ -347,11 +482,9 @@
347 482 * the My WordPress folder window. Plugins can drop additional
348 483 * stat sections (badges, milestones, contribution streaks)
349 484 * here without forking the JS render.
350 485 *
351 - * @since 0.8.0
352 - *
353 486 * @param array $payload Stats payload.
354 487 * @param int $user_id Subject user id.
355 488 */
356 - return apply_filters( 'desktop_mode_my_wordpress_user_stats', $payload, $user_id );
489 + return apply_filters( 'openstation_my_wordpress_user_stats', $payload, $user_id );
357 490 }