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 -68 0.9.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` — 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
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
@@ -22,19 +37,20 @@
22 37
23 38 /**
24 39 * Register the route.
25 40 */
26 -function desktop_mode_my_wordpress_register_user_stats_route() {
41 +function openstation_my_wordpress_register_user_stats_route() {
27 42 register_rest_route(
28 43 'desktop-mode/v1',
29 44 '/user-stats/(?P<id>\d+)',
30 45 array(
31 46 'methods' => WP_REST_Server::READABLE,
32 - 'callback' => 'desktop_mode_my_wordpress_user_stats_callback',
47 + 'callback' => 'openstation_my_wordpress_user_stats_callback',
33 48 'permission_callback' => static function () {
34 - // Logged-in users only — author archives are public,
35 - // but the dossier mixes counts that aren't.
36 - 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();
37 53 },
38 54 'args' => array(
39 55 'id' => array(
40 56 'required' => true,
@@ -44,11 +60,54 @@
44 60 ),
45 61 )
46 62 );
47 63 }
48 -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' );
49 65
50 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 +/**
51 110 * Aggregator callback. Returns the dossier shape (see file
52 111 * docblock above for fields).
53 112 *
54 113 * @param WP_REST_Request $request REST request.
@@ -53,15 +112,15 @@
53 112 *
54 113 * @param WP_REST_Request $request REST request.
55 114 * @return array|WP_Error
56 115 */
57 -function desktop_mode_my_wordpress_user_stats_callback( $request ) {
116 +function openstation_my_wordpress_user_stats_callback( $request ) {
58 117 global $wpdb;
59 118 $user_id = (int) $request->get_param( 'id' );
60 119 $user = get_userdata( $user_id );
61 120 if ( ! $user ) {
62 121 return new WP_Error(
63 - 'desktop_mode_user_not_found',
122 + 'openstation_user_not_found',
64 123 __( 'User not found.', 'desktop-mode' ),
65 124 array( 'status' => 404 )
66 125 );
67 126 }
@@ -108,9 +167,9 @@
108 167 $user_id
109 168 ),
110 169 ARRAY_A
111 170 );
112 - $post_counts = array(
171 + $post_counts = array(
113 172 'publish' => 0,
114 173 'draft' => 0,
115 174 'pending' => 0,
116 175 'private' => 0,
@@ -117,11 +176,11 @@
117 176 'future' => 0,
118 177 'total' => 0,
119 178 );
120 179 foreach ( (array) $post_status_rows as $row ) {
121 - $status = (string) $row['post_status'];
122 - $n = (int) $row['n'];
123 - $post_counts['total'] += $n;
180 + $status = (string) $row['post_status'];
181 + $n = (int) $row['n'];
182 + $post_counts['total'] += $n;
124 183 if ( isset( $post_counts[ $status ] ) ) {
125 184 $post_counts[ $status ] = $n;
126 185 }
127 186 }
@@ -138,17 +197,17 @@
138 197 $user_id
139 198 ),
140 199 ARRAY_A
141 200 );
142 - $page_counts = array(
201 + $page_counts = array(
143 202 'publish' => 0,
144 203 'draft' => 0,
145 204 'total' => 0,
146 205 );
147 206 foreach ( (array) $page_status_rows as $row ) {
148 - $status = (string) $row['post_status'];
149 - $n = (int) $row['n'];
150 - $page_counts['total'] += $n;
207 + $status = (string) $row['post_status'];
208 + $n = (int) $row['n'];
209 + $page_counts['total'] += $n;
151 210 if ( isset( $page_counts[ $status ] ) ) {
152 211 $page_counts[ $status ] = $n;
153 212 }
154 213 }
@@ -166,53 +225,134 @@
166 225 'total' => $page_counts['publish'],
167 226 );
168 227 }
169 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 +
170 251 // Comments received on posts authored by this user, approved only.
171 - // Non-privileged viewers only see engagement on published content.
172 - $received_status_sql = $can_see_private
173 - ? "p.post_status NOT IN ( 'auto-draft', 'trash' )"
174 - : "p.post_status = 'publish'";
175 - $comments_received = (int) $wpdb->get_var(
176 - $wpdb->prepare(
177 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- literal status clause chosen above.
178 - "SELECT COUNT(c.comment_ID)
179 - FROM {$wpdb->comments} c
180 - INNER JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
181 - WHERE p.post_author = %d
182 - AND c.comment_approved = '1'
183 - AND {$received_status_sql}",
184 - $user_id
185 - )
186 - );
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 + }
187 284
188 285 // Comments left BY this user (regardless of post author).
189 - $comments_left = (int) $wpdb->get_var(
190 - $wpdb->prepare(
191 - "SELECT COUNT(*)
192 - FROM {$wpdb->comments}
193 - WHERE user_id = %d
194 - AND comment_approved = '1'",
195 - $user_id
196 - )
197 - );
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 + }
198 316
199 - // Total content (posts + pages + any custom public post types).
200 - // Same gating as above: published-only unless privileged.
201 - $cpt_status_sql = $can_see_private
202 - ? "post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )"
203 - : "post_status = 'publish'";
204 - $cpt_count = (int) $wpdb->get_var(
205 - $wpdb->prepare(
206 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- literal status clause chosen above.
207 - "SELECT COUNT(*)
208 - FROM {$wpdb->posts}
209 - WHERE post_author = %d
210 - AND post_type NOT IN ( 'post', 'page', 'attachment', 'revision', 'nav_menu_item' )
211 - AND {$cpt_status_sql}",
212 - $user_id
213 - )
214 - );
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 + }
215 355
216 356 $counts = array(
217 357 'posts' => $post_counts,
218 358 'pages' => $page_counts,
@@ -236,9 +376,9 @@
236 376 'order' => 'DESC',
237 377 'suppress_filters' => false,
238 378 )
239 379 );
240 - $recent = array();
380 + $recent = array();
241 381 foreach ( (array) $recent_posts as $p ) {
242 382 if ( ! ( $p instanceof WP_Post ) ) {
243 383 continue;
244 384 }
@@ -270,9 +410,9 @@
270 410 $user_id
271 411 ),
272 412 ARRAY_A
273 413 );
274 - $top_terms = array();
414 + $top_terms = array();
275 415 foreach ( (array) $top_term_rows as $row ) {
276 416 $top_terms[] = array(
277 417 'id' => (int) $row['term_id'],
278 418 'name' => (string) $row['name'],
@@ -298,9 +438,9 @@
298 438 $user_id
299 439 ),
300 440 ARRAY_A
301 441 );
302 - $activity = array();
442 + $activity = array();
303 443 foreach ( (array) $activity_rows as $row ) {
304 444 $activity[] = array(
305 445 'ym' => (string) $row['ym'],
306 446 'count' => (int) $row['n'],
@@ -315,9 +455,9 @@
315 455 WHERE post_author = %d AND post_type IN ( 'post', 'page' ) AND post_status = 'publish'",
316 456 $user_id
317 457 )
318 458 );
319 - $last_post = $wpdb->get_var(
459 + $last_post = $wpdb->get_var(
320 460 $wpdb->prepare(
321 461 "SELECT MAX(post_date_gmt) FROM {$wpdb->posts}
322 462 WHERE post_author = %d AND post_type IN ( 'post', 'page' ) AND post_status = 'publish'",
323 463 $user_id
@@ -345,6 +485,6 @@
345 485 *
346 486 * @param array $payload Stats payload.
347 487 * @param int $user_id Subject user id.
348 488 */
349 - return apply_filters( 'desktop_mode_my_wordpress_user_stats', $payload, $user_id );
489 + return apply_filters( 'openstation_my_wordpress_user_stats', $payload, $user_id );
350 490 }