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 +113 -15 1.1.41.1.10 View file →
@@ -7,14 +7,28 @@
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 *
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 + *
17 31 * @package OpenStation
18 32 */
19 33
20 34 defined( 'ABSPATH' ) || exit;
@@ -29,9 +43,12 @@
29 43 array(
30 44 'methods' => WP_REST_Server::READABLE,
31 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,
@@ -60,9 +77,14 @@
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 88 'openstation_invalid_taxonomy',
67 89 __( 'Unknown taxonomy.', 'desktop-mode' ),
68 90 array( 'status' => 400 )
@@ -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 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(
@@ -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(