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 +231 -68 0.8.81.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,15 +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` sees full data; everyone
13 - * else sees the public subset (display name, avatar, post archive
14 - * link, public post counts). Sensitive fields (email, registered
15 - * date, role) are gated on the cap.
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.
16 20 *
17 - * @package WPDesktopMode
18 - * @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
19 34 */
20 35
21 36 defined( 'ABSPATH' ) || exit;
22 37
@@ -21,22 +36,21 @@
21 36 defined( 'ABSPATH' ) || exit;
22 37
23 38 /**
24 39 * Register the route.
25 - *
26 - * @since 0.8.0
27 40 */
28 -function desktop_mode_my_wordpress_register_user_stats_route() {
41 +function openstation_my_wordpress_register_user_stats_route() {
29 42 register_rest_route(
30 43 'desktop-mode/v1',
31 44 '/user-stats/(?P<id>\d+)',
32 45 array(
33 46 'methods' => WP_REST_Server::READABLE,
34 - 'callback' => 'desktop_mode_my_wordpress_user_stats_callback',
47 + 'callback' => 'openstation_my_wordpress_user_stats_callback',
35 48 'permission_callback' => static function () {
36 - // Logged-in users only — author archives are public,
37 - // but the dossier mixes counts that aren't.
38 - 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();
39 53 },
40 54 'args' => array(
41 55 'id' => array(
42 56 'required' => true,
@@ -46,26 +60,67 @@
46 60 ),
47 61 )
48 62 );
49 63 }
50 -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' );
51 65
52 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 +/**
53 110 * Aggregator callback. Returns the dossier shape (see file
54 111 * docblock above for fields).
55 112 *
56 - * @since 0.8.0
57 - *
58 113 * @param WP_REST_Request $request REST request.
59 114 * @return array|WP_Error
60 115 */
61 -function desktop_mode_my_wordpress_user_stats_callback( $request ) {
116 +function openstation_my_wordpress_user_stats_callback( $request ) {
62 117 global $wpdb;
63 118 $user_id = (int) $request->get_param( 'id' );
64 119 $user = get_userdata( $user_id );
65 120 if ( ! $user ) {
66 121 return new WP_Error(
67 - 'desktop_mode_user_not_found',
122 + 'openstation_user_not_found',
68 123 __( 'User not found.', 'desktop-mode' ),
69 124 array( 'status' => 404 )
70 125 );
71 126 }
@@ -112,9 +167,9 @@
112 167 $user_id
113 168 ),
114 169 ARRAY_A
115 170 );
116 - $post_counts = array(
171 + $post_counts = array(
117 172 'publish' => 0,
118 173 'draft' => 0,
119 174 'pending' => 0,
120 175 'private' => 0,
@@ -121,11 +176,11 @@
121 176 'future' => 0,
122 177 'total' => 0,
123 178 );
124 179 foreach ( (array) $post_status_rows as $row ) {
125 - $status = (string) $row['post_status'];
126 - $n = (int) $row['n'];
127 - $post_counts['total'] += $n;
180 + $status = (string) $row['post_status'];
181 + $n = (int) $row['n'];
182 + $post_counts['total'] += $n;
128 183 if ( isset( $post_counts[ $status ] ) ) {
129 184 $post_counts[ $status ] = $n;
130 185 }
131 186 }
@@ -142,57 +197,162 @@
142 197 $user_id
143 198 ),
144 199 ARRAY_A
145 200 );
146 - $page_counts = array(
201 + $page_counts = array(
147 202 'publish' => 0,
148 203 'draft' => 0,
149 204 'total' => 0,
150 205 );
151 206 foreach ( (array) $page_status_rows as $row ) {
152 - $status = (string) $row['post_status'];
153 - $n = (int) $row['n'];
154 - $page_counts['total'] += $n;
207 + $status = (string) $row['post_status'];
208 + $n = (int) $row['n'];
209 + $page_counts['total'] += $n;
155 210 if ( isset( $page_counts[ $status ] ) ) {
156 211 $page_counts[ $status ] = $n;
157 212 }
158 213 }
159 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 +
160 251 // Comments received on posts authored by this user, approved only.
161 - $comments_received = (int) $wpdb->get_var(
162 - $wpdb->prepare(
163 - "SELECT COUNT(c.comment_ID)
164 - FROM {$wpdb->comments} c
165 - INNER JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
166 - WHERE p.post_author = %d
167 - AND c.comment_approved = '1'
168 - AND p.post_status NOT IN ( 'auto-draft', 'trash' )",
169 - $user_id
170 - )
171 - );
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 + }
172 284
173 285 // Comments left BY this user (regardless of post author).
174 - $comments_left = (int) $wpdb->get_var(
175 - $wpdb->prepare(
176 - "SELECT COUNT(*)
177 - FROM {$wpdb->comments}
178 - WHERE user_id = %d
179 - AND comment_approved = '1'",
180 - $user_id
181 - )
182 - );
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 + }
183 316
184 - // Total content (posts + pages + any custom public post types).
185 - $cpt_count = (int) $wpdb->get_var(
186 - $wpdb->prepare(
187 - "SELECT COUNT(*)
188 - FROM {$wpdb->posts}
189 - WHERE post_author = %d
190 - AND post_type NOT IN ( 'post', 'page', 'attachment', 'revision', 'nav_menu_item' )
191 - AND post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )",
192 - $user_id
193 - )
194 - );
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 + }
195 355
196 356 $counts = array(
197 357 'posts' => $post_counts,
198 358 'pages' => $page_counts,
@@ -200,14 +360,18 @@
200 360 'commentsLeft' => $comments_left,
201 361 'cpt' => $cpt_count,
202 362 );
203 363
204 - // ----- Recent posts (latest 5, any public-ish status) --------------
364 + // ----- Recent posts (latest 5) -------------------------------------
365 + // Privileged viewers also see private/scheduled/draft/pending;
366 + // everyone else gets published posts only.
205 367 $recent_posts = get_posts(
206 368 array(
207 369 'author' => $user_id,
208 370 'post_type' => array( 'post', 'page' ),
209 - 'post_status' => array( 'publish', 'private', 'future', 'draft', 'pending' ),
371 + 'post_status' => $can_see_private
372 + ? array( 'publish', 'private', 'future', 'draft', 'pending' )
373 + : array( 'publish' ),
210 374 'posts_per_page' => 5,
211 375 'orderby' => 'date',
212 376 'order' => 'DESC',
213 377 'suppress_filters' => false,
@@ -212,9 +376,9 @@
212 376 'order' => 'DESC',
213 377 'suppress_filters' => false,
214 378 )
215 379 );
216 - $recent = array();
380 + $recent = array();
217 381 foreach ( (array) $recent_posts as $p ) {
218 382 if ( ! ( $p instanceof WP_Post ) ) {
219 383 continue;
220 384 }
@@ -246,9 +410,9 @@
246 410 $user_id
247 411 ),
248 412 ARRAY_A
249 413 );
250 - $top_terms = array();
414 + $top_terms = array();
251 415 foreach ( (array) $top_term_rows as $row ) {
252 416 $top_terms[] = array(
253 417 'id' => (int) $row['term_id'],
254 418 'name' => (string) $row['name'],
@@ -274,9 +438,9 @@
274 438 $user_id
275 439 ),
276 440 ARRAY_A
277 441 );
278 - $activity = array();
442 + $activity = array();
279 443 foreach ( (array) $activity_rows as $row ) {
280 444 $activity[] = array(
281 445 'ym' => (string) $row['ym'],
282 446 'count' => (int) $row['n'],
@@ -282,9 +446,10 @@
282 446 'count' => (int) $row['n'],
283 447 );
284 448 }
285 449
286 - // ----- First & last published, plus longest streak -----------------
450 + // ----- First & last published --------------------------------------
451 + // (Streak math lives in the separate user-footprint endpoint.)
287 452 $first_post = $wpdb->get_var(
288 453 $wpdb->prepare(
289 454 "SELECT MIN(post_date_gmt) FROM {$wpdb->posts}
290 455 WHERE post_author = %d AND post_type IN ( 'post', 'page' ) AND post_status = 'publish'",
@@ -290,9 +455,9 @@
290 455 WHERE post_author = %d AND post_type IN ( 'post', 'page' ) AND post_status = 'publish'",
291 456 $user_id
292 457 )
293 458 );
294 - $last_post = $wpdb->get_var(
459 + $last_post = $wpdb->get_var(
295 460 $wpdb->prepare(
296 461 "SELECT MAX(post_date_gmt) FROM {$wpdb->posts}
297 462 WHERE post_author = %d AND post_type IN ( 'post', 'page' ) AND post_status = 'publish'",
298 463 $user_id
@@ -317,11 +482,9 @@
317 482 * the My WordPress folder window. Plugins can drop additional
318 483 * stat sections (badges, milestones, contribution streaks)
319 484 * here without forking the JS render.
320 485 *
321 - * @since 0.8.0
322 - *
323 486 * @param array $payload Stats payload.
324 487 * @param int $user_id Subject user id.
325 488 */
326 - return apply_filters( 'desktop_mode_my_wordpress_user_stats', $payload, $user_id );
489 + return apply_filters( 'openstation_my_wordpress_user_stats', $payload, $user_id );
327 490 }