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 +188 -48 1.1.21.1.10 View file →
@@ -8,14 +8,29 @@
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 *
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 + *
18 33 * @package OpenStation
19 34 */
20 35
21 36 defined( 'ABSPATH' ) || exit;
@@ -30,11 +45,12 @@
30 45 array(
31 46 'methods' => WP_REST_Server::READABLE,
32 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,
@@ -47,8 +63,51 @@
47 63 }
48 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.
@@ -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,