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 -40 0.9.81.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,15 +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 + * 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
18 32 */
19 33
20 34 defined( 'ABSPATH' ) || exit;
21 35
@@ -21,17 +35,20 @@
21 35
22 36 /**
23 37 * Register the route.
24 38 */
25 -function desktop_mode_my_wordpress_register_term_stats_route() {
39 +function openstation_my_wordpress_register_term_stats_route() {
26 40 register_rest_route(
27 41 'desktop-mode/v1',
28 42 '/term-stats/(?P<taxonomy>[a-zA-Z0-9_-]+)/(?P<id>\d+)',
29 43 array(
30 44 'methods' => WP_REST_Server::READABLE,
31 - 'callback' => 'desktop_mode_my_wordpress_term_stats_callback',
45 + 'callback' => 'openstation_my_wordpress_term_stats_callback',
32 46 'permission_callback' => static function () {
33 - 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();
34 51 },
35 52 'args' => array(
36 53 'taxonomy' => array(
37 54 'required' => true,
@@ -46,9 +63,9 @@
46 63 ),
47 64 )
48 65 );
49 66 }
50 -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' );
51 68
52 69 /**
53 70 * Aggregator callback. See file docblock for return shape.
54 71 *
@@ -54,17 +71,22 @@
54 71 *
55 72 * @param WP_REST_Request $request REST request.
56 73 * @return array|WP_Error
57 74 */
58 -function desktop_mode_my_wordpress_term_stats_callback( $request ) {
75 +function openstation_my_wordpress_term_stats_callback( $request ) {
59 76 global $wpdb;
60 77 $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
61 78 $term_id = (int) $request->get_param( 'id' );
62 79
63 80 $tax_obj = get_taxonomy( $taxonomy );
64 - 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 ) ) ) {
65 87 return new WP_Error(
66 - 'desktop_mode_invalid_taxonomy',
88 + 'openstation_invalid_taxonomy',
67 89 __( 'Unknown taxonomy.', 'desktop-mode' ),
68 90 array( 'status' => 400 )
69 91 );
70 92 }
@@ -71,9 +93,9 @@
71 93
72 94 $term = get_term( $term_id, $taxonomy );
73 95 if ( ! $term || is_wp_error( $term ) ) {
74 96 return new WP_Error(
75 - 'desktop_mode_term_not_found',
97 + 'openstation_term_not_found',
76 98 __( 'Term not found.', 'desktop-mode' ),
77 99 array( 'status' => 404 )
78 100 );
79 101 }
@@ -79,21 +101,21 @@
79 101 }
80 102
81 103 // ----- Profile -----------------------------------------------------
82 104 $profile = array(
83 - 'id' => (int) $term->term_id,
84 - 'name' => $term->name,
85 - 'slug' => $term->slug,
86 - 'taxonomy' => $term->taxonomy,
87 - '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 )
88 110 ? (string) $tax_obj->labels->singular_name
89 111 : $taxonomy,
90 - 'description' => (string) $term->description,
91 - 'link' => get_term_link( $term ) instanceof WP_Error
112 + 'description' => (string) $term->description,
113 + 'link' => get_term_link( $term ) instanceof WP_Error
92 114 ? ''
93 115 : (string) get_term_link( $term ),
94 - 'parent' => (int) $term->parent,
95 - 'storedCount' => (int) $term->count, // core's published-only count
116 + 'parent' => (int) $term->parent,
117 + 'storedCount' => (int) $term->count, // core's published-only count
96 118 );
97 119 if ( $term->parent > 0 ) {
98 120 $parent = get_term( $term->parent, $taxonomy );
99 121 if ( $parent && ! is_wp_error( $parent ) ) {
@@ -102,10 +124,70 @@
102 124 }
103 125
104 126 $tt_id = (int) $term->term_taxonomy_id;
105 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 +
106 187 // ----- Counts ------------------------------------------------------
107 - // 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.
108 190 $status_rows = $wpdb->get_results(
109 191 $wpdb->prepare(
110 192 "SELECT p.post_status, COUNT(DISTINCT p.ID) AS n
111 193 FROM {$wpdb->posts} p
@@ -111,11 +193,11 @@
111 193 FROM {$wpdb->posts} p
112 194 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
113 195 WHERE tr.term_taxonomy_id = %d
114 196 AND p.post_type = 'post'
115 - AND p.post_status NOT IN ( 'auto-draft', 'inherit', 'trash' )
197 + AND {$status_clause}
116 198 GROUP BY p.post_status",
117 - $tt_id
199 + array_merge( array( $tt_id ), $status_args )
118 200 ),
119 201 ARRAY_A
120 202 );
121 203 $post_counts = array(
@@ -166,9 +248,14 @@
166 248 'commentsReceived' => $comments_received,
167 249 'distinctAuthors' => $distinct_authors,
168 250 );
169 251
170 - // ----- 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.
171 258 $recent_rows = $wpdb->get_results(
172 259 $wpdb->prepare(
173 260 "SELECT DISTINCT p.ID, p.post_title, p.post_date_gmt, p.post_status, p.post_type, p.post_author
174 261 FROM {$wpdb->posts} p
@@ -173,19 +260,27 @@
173 260 "SELECT DISTINCT p.ID, p.post_title, p.post_date_gmt, p.post_status, p.post_type, p.post_author
174 261 FROM {$wpdb->posts} p
175 262 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
176 263 WHERE tr.term_taxonomy_id = %d
177 - AND p.post_status IN ( 'publish', 'private', 'future', 'draft', 'pending' )
264 + AND {$status_clause}
178 265 AND p.post_type = 'post'
179 266 ORDER BY p.post_date_gmt DESC
180 - LIMIT 5",
181 - $tt_id
267 + LIMIT 15",
268 + array_merge( array( $tt_id ), $status_args )
182 269 ),
183 270 ARRAY_A
184 271 );
185 - $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 + }
186 278 foreach ( (array) $recent_rows as $row ) {
187 - $post_id = (int) $row['ID'];
279 + $post_id = (int) $row['ID'];
280 + if ( ! current_user_can( 'read_post', $post_id ) ) {
281 + continue;
282 + }
188 283 $author_id = (int) $row['post_author'];
189 284 $author = $author_id > 0 ? get_userdata( $author_id ) : null;
190 285 $author_arr = $author
191 286 ? array(
@@ -193,9 +288,9 @@
193 288 'name' => $author->display_name,
194 289 'avatarUrl' => get_avatar_url( $author->ID, array( 'size' => 48 ) ),
195 290 )
196 291 : null;
197 - $recent[] = array(
292 + $recent[] = array(
198 293 'id' => $post_id,
199 294 'title' => get_the_title( $post_id ),
200 295 'date' => mysql2date( 'c', (string) $row['post_date_gmt'], false ),
201 296 'status' => (string) $row['post_status'],
@@ -202,8 +297,11 @@
202 297 'type' => (string) $row['post_type'],
203 298 'link' => (string) get_permalink( $post_id ),
204 299 'author' => $author_arr,
205 300 );
301 + if ( count( $recent ) >= 5 ) {
302 + break;
303 + }
206 304 }
207 305
208 306 // ----- Top authors (most posts in this term) -----------------------
209 307 $top_author_rows = $wpdb->get_results(
@@ -220,9 +318,9 @@
220 318 $tt_id
221 319 ),
222 320 ARRAY_A
223 321 );
224 - $top_authors = array();
322 + $top_authors = array();
225 323 foreach ( (array) $top_author_rows as $row ) {
226 324 $user_id = (int) $row['post_author'];
227 325 $u = get_userdata( $user_id );
228 326 if ( ! $u ) {
@@ -257,9 +355,9 @@
257 355 $term_id
258 356 ),
259 357 ARRAY_A
260 358 );
261 - $co_terms = array();
359 + $co_terms = array();
262 360 foreach ( (array) $co_term_rows as $row ) {
263 361 $co_terms[] = array(
264 362 'id' => (int) $row['term_id'],
265 363 'name' => (string) $row['name'],
@@ -283,9 +381,9 @@
283 381 $tt_id
284 382 ),
285 383 ARRAY_A
286 384 );
287 - $activity = array();
385 + $activity = array();
288 386 foreach ( (array) $activity_rows as $row ) {
289 387 $activity[] = array(
290 388 'ym' => (string) $row['ym'],
291 389 'count' => (int) $row['n'],
@@ -303,9 +401,9 @@
303 401 AND p.post_type = 'post'",
304 402 $tt_id
305 403 )
306 404 );
307 - $last_post_date = $wpdb->get_var(
405 + $last_post_date = $wpdb->get_var(
308 406 $wpdb->prepare(
309 407 "SELECT MAX( p.post_date_gmt )
310 408 FROM {$wpdb->posts} p
311 409 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
@@ -314,9 +412,9 @@
314 412 AND p.post_type = 'post'",
315 413 $tt_id
316 414 )
317 415 );
318 - $milestones = array(
416 + $milestones = array(
319 417 'firstPosted' => $first_post_date ? mysql2date( 'c', $first_post_date, false ) : null,
320 418 'lastPosted' => $last_post_date ? mysql2date( 'c', $last_post_date, false ) : null,
321 419 );
322 420
@@ -338,9 +436,9 @@
338 436 * @param string $taxonomy Taxonomy slug.
339 437 * @param int $term_id Term id.
340 438 */
341 439 return apply_filters(
342 - 'desktop_mode_my_wordpress_term_stats',
440 + 'openstation_my_wordpress_term_stats',
343 441 $payload,
344 442 $taxonomy,
345 443 $term_id
346 444 );