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/term-stats.php +138 -47 0.9.01.1.10 View file →
@@ -1,7 +1,7 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — My WordPress: per-term stats endpoint.
3 + * OpenStation — My WordPress: per-term stats endpoint.
4 4 *
5 5 * `GET /desktop-mode/v1/term-stats/<taxonomy>/<id>` returns an
6 6 * aggregated profile for a single category or tag — counts, recent
7 7 * posts in the term, top authors, co-occurring terms, 12-month
@@ -7,16 +7,29 @@
7 7 * posts in the term, top authors, co-occurring terms, 12-month
8 8 * activity sparkline, milestones. Powers the right preview pane in
9 9 * the My WordPress folder when a term is selected.
10 10 *
11 - * Permissions: any logged-in user with `read` (default for most
12 - * roles) — terms are public-facing data on the WP site, so the same
13 - * cap that lets you read the front-end is enough to inspect their
14 - * stats. Author archives are also public so listing top authors is
15 - * not new disclosure.
11 + * Permissions: the My WordPress module's gate,
12 + * `openstation_my_wordpress_user_can_use()` (`edit_posts` unless a site
13 + * filters it), so a site that narrows WP Explorer narrows this data
14 + * with it. Terms are public-facing data and author archives are
15 + * public, so the term row and its top authors are no new disclosure to
16 + * anyone past that gate.
16 17 *
17 - * @package WPDesktopMode
18 - * @since 0.8.0
18 + * That reasoning covers the term row and the aggregates over its
19 + * *published* posts; it does not carry to the unpublished posts inside
20 + * the term, nor to terms of a non-viewable taxonomy. So hidden
21 + * taxonomies answer 400 unless the caller can manage their terms,
22 + * every post-level query is scoped to the statuses the caller may
23 + * read — resolved from each status's registered visibility flags and
24 + * the post type's cap map, plus the caller's own posts — and the
25 + * recent list is gated per row with `read_post`. Otherwise a
26 + * subscriber could read an administrator's private and draft post
27 + * titles, authors and dates, and the per-status counts would leak how
28 + * many hidden posts a term holds. The readable-status clause is built
29 + * in the callback, right above the queries that splice it in.
30 + *
31 + * @package OpenStation
19 32 */
20 33
21 34 defined( 'ABSPATH' ) || exit;
22 35
@@ -21,20 +34,21 @@
21 34 defined( 'ABSPATH' ) || exit;
22 35
23 36 /**
24 37 * Register the route.
25 - *
26 - * @since 0.8.0
27 38 */
28 -function desktop_mode_my_wordpress_register_term_stats_route() {
39 +function openstation_my_wordpress_register_term_stats_route() {
29 40 register_rest_route(
30 41 'desktop-mode/v1',
31 42 '/term-stats/(?P<taxonomy>[a-zA-Z0-9_-]+)/(?P<id>\d+)',
32 43 array(
33 44 'methods' => WP_REST_Server::READABLE,
34 - 'callback' => 'desktop_mode_my_wordpress_term_stats_callback',
45 + 'callback' => 'openstation_my_wordpress_term_stats_callback',
35 46 'permission_callback' => static function () {
36 - return is_user_logged_in() && current_user_can( 'read' );
47 + // The module's gate, so a site that narrows WP Explorer
48 + // narrows this data with it. The per-viewer scoping lives
49 + // in the callback, which in-process callers invoke directly.
50 + return openstation_my_wordpress_user_can_use();
37 51 },
38 52 'args' => array(
39 53 'taxonomy' => array(
40 54 'required' => true,
@@ -49,27 +63,30 @@
49 63 ),
50 64 )
51 65 );
52 66 }
53 -add_action( 'rest_api_init', 'desktop_mode_my_wordpress_register_term_stats_route' );
67 +add_action( 'rest_api_init', 'openstation_my_wordpress_register_term_stats_route' );
54 68
55 69 /**
56 70 * Aggregator callback. See file docblock for return shape.
57 71 *
58 - * @since 0.8.0
59 - *
60 72 * @param WP_REST_Request $request REST request.
61 73 * @return array|WP_Error
62 74 */
63 -function desktop_mode_my_wordpress_term_stats_callback( $request ) {
75 +function openstation_my_wordpress_term_stats_callback( $request ) {
64 76 global $wpdb;
65 77 $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
66 78 $term_id = (int) $request->get_param( 'id' );
67 79
68 80 $tax_obj = get_taxonomy( $taxonomy );
69 - if ( ! $tax_obj ) {
81 + // A registered-but-hidden taxonomy (nav_menu, link_category, a
82 + // plugin's internal one) is not public-facing data the way
83 + // categories and tags are, so the file docblock's `read` reasoning
84 + // does not cover it: answer exactly as if it were unregistered
85 + // unless the caller can manage its terms.
86 + if ( ! $tax_obj || ( ! is_taxonomy_viewable( $tax_obj ) && ! current_user_can( $tax_obj->cap->manage_terms ) ) ) {
70 87 return new WP_Error(
71 - 'desktop_mode_invalid_taxonomy',
88 + 'openstation_invalid_taxonomy',
72 89 __( 'Unknown taxonomy.', 'desktop-mode' ),
73 90 array( 'status' => 400 )
74 91 );
75 92 }
@@ -76,9 +93,9 @@
76 93
77 94 $term = get_term( $term_id, $taxonomy );
78 95 if ( ! $term || is_wp_error( $term ) ) {
79 96 return new WP_Error(
80 - 'desktop_mode_term_not_found',
97 + 'openstation_term_not_found',
81 98 __( 'Term not found.', 'desktop-mode' ),
82 99 array( 'status' => 404 )
83 100 );
84 101 }
@@ -84,21 +101,21 @@
84 101 }
85 102
86 103 // ----- Profile -----------------------------------------------------
87 104 $profile = array(
88 - 'id' => (int) $term->term_id,
89 - 'name' => $term->name,
90 - 'slug' => $term->slug,
91 - 'taxonomy' => $term->taxonomy,
92 - 'taxonomyLabel' => isset( $tax_obj->labels->singular_name )
105 + 'id' => (int) $term->term_id,
106 + 'name' => $term->name,
107 + 'slug' => $term->slug,
108 + 'taxonomy' => $term->taxonomy,
109 + 'taxonomyLabel' => isset( $tax_obj->labels->singular_name )
93 110 ? (string) $tax_obj->labels->singular_name
94 111 : $taxonomy,
95 - 'description' => (string) $term->description,
96 - 'link' => get_term_link( $term ) instanceof WP_Error
112 + 'description' => (string) $term->description,
113 + 'link' => get_term_link( $term ) instanceof WP_Error
97 114 ? ''
98 115 : (string) get_term_link( $term ),
99 - 'parent' => (int) $term->parent,
100 - 'storedCount' => (int) $term->count, // core's published-only count
116 + 'parent' => (int) $term->parent,
117 + 'storedCount' => (int) $term->count, // core's published-only count
101 118 );
102 119 if ( $term->parent > 0 ) {
103 120 $parent = get_term( $term->parent, $taxonomy );
104 121 if ( $parent && ! is_wp_error( $parent ) ) {
@@ -107,10 +124,70 @@
107 124 }
108 125
109 126 $tt_id = (int) $term->term_taxonomy_id;
110 127
128 + // Every query below that can touch unpublished posts is scoped to
129 + // the statuses the caller may read (the remaining aggregates are
130 + // publish-only). The endpoint gates on the term (public), but the
131 + // posts inside it are not: without this, a subscriber gets the
132 + // titles, authors and dates of administrator-owned drafts/private
133 + // posts, and the per-status counts become an oracle for content
134 + // they cannot see.
135 + //
136 + // The sets come from the registered status objects, so a plugin's
137 + // custom status follows its own visibility flags: public statuses
138 + // for everyone; private-flagged ones with the post type's
139 + // read_private_posts; the remaining non-internal statuses (draft,
140 + // pending, future and any registered workflow status — trash and
141 + // auto-draft are internal) with edit_others_posts, because core
142 + // maps reading them to editing them, plus edit_published_posts for
143 + // a scheduled post, mirroring map_meta_cap(); and the caller's own
144 + // posts in any of those statuses, since core grants an author read
145 + // on their own post whatever its status. The clause is a close
146 + // approximation of read_post used where a per-row gate is
147 + // impossible (the counts); the recent list re-checks read_post per
148 + // row as the authoritative gate. It is built inline, from literal
149 + // %s/%d placeholder lists only, so its values are visibly bound
150 + // through prepare() at both use sites.
151 + $type = get_post_type_object( 'post' );
152 + $statuses = array_values( get_post_stati( array( 'public' => true ) ) );
153 + $private_stati = array_values( get_post_stati( array( 'private' => true ) ) );
154 + $hidden_stati = array_values(
155 + get_post_stati(
156 + array(
157 + 'internal' => false,
158 + 'public' => false,
159 + 'private' => false,
160 + )
161 + )
162 + );
163 + if ( current_user_can( $type->cap->read_private_posts ) ) {
164 + $statuses = array_merge( $statuses, $private_stati );
165 + }
166 + if ( current_user_can( $type->cap->edit_others_posts ) ) {
167 + foreach ( $hidden_stati as $status ) {
168 + if ( 'future' === $status && ! current_user_can( $type->cap->edit_published_posts ) ) {
169 + continue;
170 + }
171 + $statuses[] = $status;
172 + }
173 + }
174 +
175 + $placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) );
176 + $status_clause = "p.post_status IN ( {$placeholders} )";
177 + $status_args = $statuses;
178 +
179 + $user_id = get_current_user_id();
180 + $own = array_values( array_diff( array_merge( $private_stati, $hidden_stati ), $statuses ) );
181 + if ( $user_id > 0 && $own ) {
182 + $own_ph = implode( ', ', array_fill( 0, count( $own ), '%s' ) );
183 + $status_clause = "( {$status_clause} OR ( p.post_author = %d AND p.post_status IN ( {$own_ph} ) ) )";
184 + $status_args = array_merge( $status_args, array( $user_id ), $own );
185 + }
186 +
111 187 // ----- Counts ------------------------------------------------------
112 - // Post-status breakdown for posts in this term.
188 + // Post-status breakdown, restricted to the readable set so the
189 + // counts never reveal how many hidden posts a term holds.
113 190 $status_rows = $wpdb->get_results(
114 191 $wpdb->prepare(
115 192 "SELECT p.post_status, COUNT(DISTINCT p.ID) AS n
116 193 FROM {$wpdb->posts} p
@@ -116,11 +193,11 @@
116 193 FROM {$wpdb->posts} p
117 194 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
118 195 WHERE tr.term_taxonomy_id = %d
119 196 AND p.post_type = 'post'
120 - AND p.post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
197 + AND {$status_clause}
121 198 GROUP BY p.post_status",
122 - $tt_id
199 + array_merge( array( $tt_id ), $status_args )
123 200 ),
124 201 ARRAY_A
125 202 );
126 203 $post_counts = array(
@@ -171,9 +248,14 @@
171 248 'commentsReceived' => $comments_received,
172 249 'distinctAuthors' => $distinct_authors,
173 250 );
174 251
175 - // ----- Recent posts (5 most recent) --------------------------------
252 + // ----- Recent posts (5 most recent the caller may read) ------------
253 + // The clause narrows the pool to readable statuses; the per-row
254 + // read_post gate below is authoritative (it resolves the exact meta
255 + // cap per post, and it is the hook where membership plugins restrict
256 + // even published posts). Fetch headroom past 5 because the gate may
257 + // drop rows the coarse clause admitted.
176 258 $recent_rows = $wpdb->get_results(
177 259 $wpdb->prepare(
178 260 "SELECT DISTINCT p.ID, p.post_title, p.post_date_gmt, p.post_status, p.post_type, p.post_author
179 261 FROM {$wpdb->posts} p
@@ -178,19 +260,27 @@
178 260 "SELECT DISTINCT p.ID, p.post_title, p.post_date_gmt, p.post_status, p.post_type, p.post_author
179 261 FROM {$wpdb->posts} p
180 262 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
181 263 WHERE tr.term_taxonomy_id = %d
182 - AND p.post_status IN ( 'publish', 'private', 'future', 'draft', 'pending' )
264 + AND {$status_clause}
183 265 AND p.post_type = 'post'
184 266 ORDER BY p.post_date_gmt DESC
185 - LIMIT 5",
186 - $tt_id
267 + LIMIT 15",
268 + array_merge( array( $tt_id ), $status_args )
187 269 ),
188 270 ARRAY_A
189 271 );
190 - $recent = array();
272 + $recent = array();
273 + if ( $recent_rows ) {
274 + // Bulk-warm the post cache — the read_post checks,
275 + // get_the_title() and get_permalink() below all read from it.
276 + _prime_post_caches( array_map( 'intval', wp_list_pluck( $recent_rows, 'ID' ) ), false, false );
277 + }
191 278 foreach ( (array) $recent_rows as $row ) {
192 - $post_id = (int) $row['ID'];
279 + $post_id = (int) $row['ID'];
280 + if ( ! current_user_can( 'read_post', $post_id ) ) {
281 + continue;
282 + }
193 283 $author_id = (int) $row['post_author'];
194 284 $author = $author_id > 0 ? get_userdata( $author_id ) : null;
195 285 $author_arr = $author
196 286 ? array(
@@ -198,9 +288,9 @@
198 288 'name' => $author->display_name,
199 289 'avatarUrl' => get_avatar_url( $author->ID, array( 'size' => 48 ) ),
200 290 )
201 291 : null;
202 - $recent[] = array(
292 + $recent[] = array(
203 293 'id' => $post_id,
204 294 'title' => get_the_title( $post_id ),
205 295 'date' => mysql2date( 'c', (string) $row['post_date_gmt'], false ),
206 296 'status' => (string) $row['post_status'],
@@ -207,8 +297,11 @@
207 297 'type' => (string) $row['post_type'],
208 298 'link' => (string) get_permalink( $post_id ),
209 299 'author' => $author_arr,
210 300 );
301 + if ( count( $recent ) >= 5 ) {
302 + break;
303 + }
211 304 }
212 305
213 306 // ----- Top authors (most posts in this term) -----------------------
214 307 $top_author_rows = $wpdb->get_results(
@@ -225,9 +318,9 @@
225 318 $tt_id
226 319 ),
227 320 ARRAY_A
228 321 );
229 - $top_authors = array();
322 + $top_authors = array();
230 323 foreach ( (array) $top_author_rows as $row ) {
231 324 $user_id = (int) $row['post_author'];
232 325 $u = get_userdata( $user_id );
233 326 if ( ! $u ) {
@@ -262,9 +355,9 @@
262 355 $term_id
263 356 ),
264 357 ARRAY_A
265 358 );
266 - $co_terms = array();
359 + $co_terms = array();
267 360 foreach ( (array) $co_term_rows as $row ) {
268 361 $co_terms[] = array(
269 362 'id' => (int) $row['term_id'],
270 363 'name' => (string) $row['name'],
@@ -288,9 +381,9 @@
288 381 $tt_id
289 382 ),
290 383 ARRAY_A
291 384 );
292 - $activity = array();
385 + $activity = array();
293 386 foreach ( (array) $activity_rows as $row ) {
294 387 $activity[] = array(
295 388 'ym' => (string) $row['ym'],
296 389 'count' => (int) $row['n'],
@@ -308,9 +401,9 @@
308 401 AND p.post_type = 'post'",
309 402 $tt_id
310 403 )
311 404 );
312 - $last_post_date = $wpdb->get_var(
405 + $last_post_date = $wpdb->get_var(
313 406 $wpdb->prepare(
314 407 "SELECT MAX( p.post_date_gmt )
315 408 FROM {$wpdb->posts} p
316 409 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
@@ -319,9 +412,9 @@
319 412 AND p.post_type = 'post'",
320 413 $tt_id
321 414 )
322 415 );
323 - $milestones = array(
416 + $milestones = array(
324 417 'firstPosted' => $first_post_date ? mysql2date( 'c', $first_post_date, false ) : null,
325 418 'lastPosted' => $last_post_date ? mysql2date( 'c', $last_post_date, false ) : null,
326 419 );
327 420
@@ -338,16 +431,14 @@
338 431 /**
339 432 * Filter the per-term stats payload before it returns to the
340 433 * My WordPress folder window.
341 434 *
342 - * @since 0.8.0
343 - *
344 435 * @param array $payload Stats payload.
345 436 * @param string $taxonomy Taxonomy slug.
346 437 * @param int $term_id Term id.
347 438 */
348 439 return apply_filters(
349 - 'desktop_mode_my_wordpress_term_stats',
440 + 'openstation_my_wordpress_term_stats',
350 441 $payload,
351 442 $taxonomy,
352 443 $term_id
353 444 );