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
desktop-mode / includes / my-wordpress / term-stats.php

term-stats.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.10, at includes/my-wordpress/term-stats.php

446 lines 14.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — My WordPress: per-term stats endpoint.
4 *
5 * `GET /desktop-mode/v1/term-stats/<taxonomy>/<id>` returns an
6 * aggregated profile for a single category or tag — counts, recent
7 * posts in the term, top authors, co-occurring terms, 12-month
8 * activity sparkline, milestones. Powers the right preview pane in
9 * the My WordPress folder when a term is selected.
10 *
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.
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 *
31 * @package OpenStation
32 */
33
34 defined( 'ABSPATH' ) || exit;
35
36 /**
37 * Register the route.
38 */
39 function openstation_my_wordpress_register_term_stats_route() {
40 register_rest_route(
41 'desktop-mode/v1',
42 '/term-stats/(?P<taxonomy>[a-zA-Z0-9_-]+)/(?P<id>\d+)',
43 array(
44 'methods' => WP_REST_Server::READABLE,
45 'callback' => 'openstation_my_wordpress_term_stats_callback',
46 'permission_callback' => static function () {
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();
51 },
52 'args' => array(
53 'taxonomy' => array(
54 'required' => true,
55 'type' => 'string',
56 'sanitize_callback' => 'sanitize_key',
57 ),
58 'id' => array(
59 'required' => true,
60 'type' => 'integer',
61 'sanitize_callback' => 'absint',
62 ),
63 ),
64 )
65 );
66 }
67 add_action( 'rest_api_init', 'openstation_my_wordpress_register_term_stats_route' );
68
69 /**
70 * Aggregator callback. See file docblock for return shape.
71 *
72 * @param WP_REST_Request $request REST request.
73 * @return array|WP_Error
74 */
75 function openstation_my_wordpress_term_stats_callback( $request ) {
76 global $wpdb;
77 $taxonomy = sanitize_key( (string) $request->get_param( 'taxonomy' ) );
78 $term_id = (int) $request->get_param( 'id' );
79
80 $tax_obj = get_taxonomy( $taxonomy );
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 ) ) ) {
87 return new WP_Error(
88 'openstation_invalid_taxonomy',
89 __( 'Unknown taxonomy.', 'desktop-mode' ),
90 array( 'status' => 400 )
91 );
92 }
93
94 $term = get_term( $term_id, $taxonomy );
95 if ( ! $term || is_wp_error( $term ) ) {
96 return new WP_Error(
97 'openstation_term_not_found',
98 __( 'Term not found.', 'desktop-mode' ),
99 array( 'status' => 404 )
100 );
101 }
102
103 // ----- Profile -----------------------------------------------------
104 $profile = array(
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 )
110 ? (string) $tax_obj->labels->singular_name
111 : $taxonomy,
112 'description' => (string) $term->description,
113 'link' => get_term_link( $term ) instanceof WP_Error
114 ? ''
115 : (string) get_term_link( $term ),
116 'parent' => (int) $term->parent,
117 'storedCount' => (int) $term->count, // core's published-only count
118 );
119 if ( $term->parent > 0 ) {
120 $parent = get_term( $term->parent, $taxonomy );
121 if ( $parent && ! is_wp_error( $parent ) ) {
122 $profile['parentName'] = $parent->name;
123 }
124 }
125
126 $tt_id = (int) $term->term_taxonomy_id;
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
187 // ----- Counts ------------------------------------------------------
188 // Post-status breakdown, restricted to the readable set so the
189 // counts never reveal how many hidden posts a term holds.
190 $status_rows = $wpdb->get_results(
191 $wpdb->prepare(
192 "SELECT p.post_status, COUNT(DISTINCT p.ID) AS n
193 FROM {$wpdb->posts} p
194 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
195 WHERE tr.term_taxonomy_id = %d
196 AND p.post_type = 'post'
197 AND {$status_clause}
198 GROUP BY p.post_status",
199 array_merge( array( $tt_id ), $status_args )
200 ),
201 ARRAY_A
202 );
203 $post_counts = array(
204 'publish' => 0,
205 'draft' => 0,
206 'pending' => 0,
207 'private' => 0,
208 'future' => 0,
209 'total' => 0,
210 );
211 foreach ( (array) $status_rows as $row ) {
212 $status = (string) $row['post_status'];
213 $n = (int) $row['n'];
214 $post_counts['total'] += $n;
215 if ( isset( $post_counts[ $status ] ) ) {
216 $post_counts[ $status ] = $n;
217 }
218 }
219
220 // Comments on posts in this term (approved only).
221 $comments_received = (int) $wpdb->get_var(
222 $wpdb->prepare(
223 "SELECT COUNT(c.comment_ID)
224 FROM {$wpdb->comments} c
225 INNER JOIN {$wpdb->posts} p ON c.comment_post_ID = p.ID
226 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
227 WHERE tr.term_taxonomy_id = %d
228 AND p.post_status = 'publish'
229 AND c.comment_approved = '1'",
230 $tt_id
231 )
232 );
233
234 // Distinct authors using this term.
235 $distinct_authors = (int) $wpdb->get_var(
236 $wpdb->prepare(
237 "SELECT COUNT( DISTINCT p.post_author )
238 FROM {$wpdb->posts} p
239 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
240 WHERE tr.term_taxonomy_id = %d
241 AND p.post_status = 'publish'",
242 $tt_id
243 )
244 );
245
246 $counts = array(
247 'posts' => $post_counts,
248 'commentsReceived' => $comments_received,
249 'distinctAuthors' => $distinct_authors,
250 );
251
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.
258 $recent_rows = $wpdb->get_results(
259 $wpdb->prepare(
260 "SELECT DISTINCT p.ID, p.post_title, p.post_date_gmt, p.post_status, p.post_type, p.post_author
261 FROM {$wpdb->posts} p
262 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
263 WHERE tr.term_taxonomy_id = %d
264 AND {$status_clause}
265 AND p.post_type = 'post'
266 ORDER BY p.post_date_gmt DESC
267 LIMIT 15",
268 array_merge( array( $tt_id ), $status_args )
269 ),
270 ARRAY_A
271 );
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 }
278 foreach ( (array) $recent_rows as $row ) {
279 $post_id = (int) $row['ID'];
280 if ( ! current_user_can( 'read_post', $post_id ) ) {
281 continue;
282 }
283 $author_id = (int) $row['post_author'];
284 $author = $author_id > 0 ? get_userdata( $author_id ) : null;
285 $author_arr = $author
286 ? array(
287 'id' => (int) $author->ID,
288 'name' => $author->display_name,
289 'avatarUrl' => get_avatar_url( $author->ID, array( 'size' => 48 ) ),
290 )
291 : null;
292 $recent[] = array(
293 'id' => $post_id,
294 'title' => get_the_title( $post_id ),
295 'date' => mysql2date( 'c', (string) $row['post_date_gmt'], false ),
296 'status' => (string) $row['post_status'],
297 'type' => (string) $row['post_type'],
298 'link' => (string) get_permalink( $post_id ),
299 'author' => $author_arr,
300 );
301 if ( count( $recent ) >= 5 ) {
302 break;
303 }
304 }
305
306 // ----- Top authors (most posts in this term) -----------------------
307 $top_author_rows = $wpdb->get_results(
308 $wpdb->prepare(
309 "SELECT p.post_author, COUNT( DISTINCT p.ID ) AS n
310 FROM {$wpdb->posts} p
311 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
312 WHERE tr.term_taxonomy_id = %d
313 AND p.post_status = 'publish'
314 AND p.post_author > 0
315 GROUP BY p.post_author
316 ORDER BY n DESC
317 LIMIT 5",
318 $tt_id
319 ),
320 ARRAY_A
321 );
322 $top_authors = array();
323 foreach ( (array) $top_author_rows as $row ) {
324 $user_id = (int) $row['post_author'];
325 $u = get_userdata( $user_id );
326 if ( ! $u ) {
327 continue;
328 }
329 $top_authors[] = array(
330 'userId' => (int) $u->ID,
331 'userName' => (string) $u->display_name,
332 'userAvatarUrl' => (string) get_avatar_url( $u->ID, array( 'size' => 48 ) ),
333 'count' => (int) $row['n'],
334 );
335 }
336
337 // ----- Co-occurring terms (most frequent siblings in same tax) -----
338 $co_term_rows = $wpdb->get_results(
339 $wpdb->prepare(
340 "SELECT t.term_id, t.name, t.slug, COUNT(*) AS n
341 FROM {$wpdb->term_relationships} tr1
342 INNER JOIN {$wpdb->term_relationships} tr2 ON tr1.object_id = tr2.object_id
343 INNER JOIN {$wpdb->term_taxonomy} tt2 ON tr2.term_taxonomy_id = tt2.term_taxonomy_id
344 INNER JOIN {$wpdb->terms} t ON tt2.term_id = t.term_id
345 INNER JOIN {$wpdb->posts} p ON p.ID = tr1.object_id
346 WHERE tr1.term_taxonomy_id = %d
347 AND tt2.taxonomy = %s
348 AND tt2.term_id != %d
349 AND p.post_status = 'publish'
350 GROUP BY t.term_id
351 ORDER BY n DESC
352 LIMIT 5",
353 $tt_id,
354 $taxonomy,
355 $term_id
356 ),
357 ARRAY_A
358 );
359 $co_terms = array();
360 foreach ( (array) $co_term_rows as $row ) {
361 $co_terms[] = array(
362 'id' => (int) $row['term_id'],
363 'name' => (string) $row['name'],
364 'slug' => (string) $row['slug'],
365 'count' => (int) $row['n'],
366 );
367 }
368
369 // ----- 12-month activity sparkline ---------------------------------
370 $activity_rows = $wpdb->get_results(
371 $wpdb->prepare(
372 "SELECT DATE_FORMAT( p.post_date_gmt, '%%Y-%%m' ) AS ym, COUNT( DISTINCT p.ID ) AS n
373 FROM {$wpdb->posts} p
374 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
375 WHERE tr.term_taxonomy_id = %d
376 AND p.post_type = 'post'
377 AND p.post_status = 'publish'
378 AND p.post_date_gmt >= DATE_SUB( NOW(), INTERVAL 12 MONTH )
379 GROUP BY ym
380 ORDER BY ym ASC",
381 $tt_id
382 ),
383 ARRAY_A
384 );
385 $activity = array();
386 foreach ( (array) $activity_rows as $row ) {
387 $activity[] = array(
388 'ym' => (string) $row['ym'],
389 'count' => (int) $row['n'],
390 );
391 }
392
393 // ----- First & last post in this term ------------------------------
394 $first_post_date = $wpdb->get_var(
395 $wpdb->prepare(
396 "SELECT MIN( p.post_date_gmt )
397 FROM {$wpdb->posts} p
398 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
399 WHERE tr.term_taxonomy_id = %d
400 AND p.post_status = 'publish'
401 AND p.post_type = 'post'",
402 $tt_id
403 )
404 );
405 $last_post_date = $wpdb->get_var(
406 $wpdb->prepare(
407 "SELECT MAX( p.post_date_gmt )
408 FROM {$wpdb->posts} p
409 INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
410 WHERE tr.term_taxonomy_id = %d
411 AND p.post_status = 'publish'
412 AND p.post_type = 'post'",
413 $tt_id
414 )
415 );
416 $milestones = array(
417 'firstPosted' => $first_post_date ? mysql2date( 'c', $first_post_date, false ) : null,
418 'lastPosted' => $last_post_date ? mysql2date( 'c', $last_post_date, false ) : null,
419 );
420
421 $payload = array(
422 'profile' => $profile,
423 'counts' => $counts,
424 'recent' => $recent,
425 'topAuthors' => $top_authors,
426 'coTerms' => $co_terms,
427 'activity' => $activity,
428 'milestones' => $milestones,
429 );
430
431 /**
432 * Filter the per-term stats payload before it returns to the
433 * My WordPress folder window.
434 *
435 * @param array $payload Stats payload.
436 * @param string $taxonomy Taxonomy slug.
437 * @param int $term_id Term id.
438 */
439 return apply_filters(
440 'openstation_my_wordpress_term_stats',
441 $payload,
442 $taxonomy,
443 $term_id
444 );
445 }
446