PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.9
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.9
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 0.8.6 All 33 releases
desktop-mode / includes / my-wordpress / comment-stats.php

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

396 lines 14.7 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-comment dossier endpoint.
4 *
5 * `GET /desktop-mode/v1/comment-stats/<id>` returns the rendered
6 * comment + author + parent post + thread context + reply tree +
7 * a count of how active the author has been across the site. Powers
8 * the right preview pane in the My WordPress folder when a comment
9 * is selected.
10 *
11 * Permissions, in gate order:
12 * - The route wears the module's authorization gate,
13 * `openstation_my_wordpress_user_can_use()` — `edit_posts` by
14 * default, filterable. (That gate does *not* decide WP Explorer's
15 * window or launcher; the app declares its own capabilities. See
16 * the helper's docblock in `window.php`.)
17 * - The caller must be able to read the comment's parent post —
18 * see `openstation_my_wordpress_can_read_comment_post()` — so a
19 * low-capability author cannot read comments on posts they can't
20 * otherwise see: private, sealed behind a password, or of a post
21 * type with no readable front end at all.
22 * - Past those two gates, the comment itself must be visible —
23 * `openstation_my_wordpress_comment_is_visible()`: approved, OR the
24 * user can `moderate_comments`, OR they're the comment author.
25 * - The thread around it is scoped to the post those two gates just
26 * authorized: `comment_post_ID` and `comment_parent` are independent
27 * columns, so "the parent of a readable comment" is not by itself a
28 * readable comment. Within that post the two thread members are
29 * filtered differently:
30 * - the parent runs the same visibility test as the requested
31 * comment (approved, moderator, or own);
32 * - replies are approved only — plus pending ones for a
33 * moderator, as a moderation aid. Spam and trash never ship,
34 * and there is no own-reply exception.
35 * - Author email / IP / user-agent only ship to viewers with
36 * `moderate_comments`.
37 *
38 * @package OpenStation
39 */
40
41 defined( 'ABSPATH' ) || exit;
42
43 /**
44 * Register the route.
45 */
46 function openstation_my_wordpress_register_comment_stats_route() {
47 register_rest_route(
48 'desktop-mode/v1',
49 '/comment-stats/(?P<id>\d+)',
50 array(
51 'methods' => WP_REST_Server::READABLE,
52 'callback' => 'openstation_my_wordpress_comment_stats_callback',
53 'permission_callback' => static function () {
54 // The dossier is an author's tool, so it wears the
55 // module's authorization gate rather than a gate of its
56 // own. Bare is_user_logged_in() let any subscriber read
57 // it (OPENSTA-155). WP Explorer's window and launcher
58 // are gated separately, by the app's own capabilities.
59 return openstation_my_wordpress_user_can_use();
60 },
61 'args' => array(
62 'id' => array(
63 'required' => true,
64 'type' => 'integer',
65 'sanitize_callback' => 'absint',
66 ),
67 ),
68 )
69 );
70 }
71 add_action( 'rest_api_init', 'openstation_my_wordpress_register_comment_stats_route' );
72
73 /**
74 * Whether the current user may read the post a comment belongs to.
75 *
76 * Three gates, in order:
77 *
78 * 1. **No parent at all.** An orphaned comment (`comment_post_ID` of
79 * 0, or a post since deleted) has nothing to authorize against, so
80 * it is moderators-only.
81 * 2. **A sealed parent** needs `edit_post` — the escape hatch
82 * `WP_REST_Posts_Controller::check_password_required()` grants.
83 * Note `post_password_required()` reads the `wp-postpass` cookie:
84 * a caller who has already entered the password is *not* looking at
85 * a sealed post, and falls through to the gates below like any
86 * other reader.
87 * 3. **A parent whose post type has no readable front end** needs
88 * `edit_post` too. This is the branch `read_post` alone misses:
89 * `map_meta_cap()` resolves `read_post` on a *published* post to
90 * the type's `read` capability, which is plain `read` on any post
91 * type registered with `map_meta_cap`, and every logged-in user
92 * holds it. So a published post of an internal post type — a
93 * plugin's submission log, queue or internal note, none of which
94 * a visitor can open — would otherwise read like a public post.
95 * `openstation_ai_can_read_post()` takes the same position.
96 *
97 * Anything else is Core's `read_post`, matching
98 * `WP_REST_Comments_Controller::check_read_post_permission()`. Unlike
99 * the AI sibling this keeps `read_post` as the floor for viewable types
100 * rather than short-circuiting publicly viewable posts: that helper
101 * serves search, which is about public content, while this one mirrors
102 * Core's single-comment read.
103 *
104 * @param WP_Post|null $post Parent post, or null when it no longer exists.
105 * @return bool
106 */
107 function openstation_my_wordpress_can_read_comment_post( $post ) {
108 if ( ! $post ) {
109 return current_user_can( 'moderate_comments' );
110 }
111 if ( post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) {
112 return false;
113 }
114 $post_type = get_post_type_object( $post->post_type );
115 if ( ! $post_type || ! is_post_type_viewable( $post_type ) ) {
116 return current_user_can( 'edit_post', $post->ID );
117 }
118 return current_user_can( 'read_post', $post->ID );
119 }
120
121 /**
122 * Whether a comment's own moderation state lets the current user see it.
123 *
124 * The parent-post gate above decides whether the caller may see comments
125 * on that post at all; this decides whether they may see *this* comment:
126 * approved ones are public, anything pending, spam or trashed is for
127 * moderators and for the person who wrote it.
128 *
129 * Applied to the requested comment and to the thread parent alike —
130 * the parent is reached by id, not by a query that filters on status,
131 * so without this its excerpt would ship whatever its status. Replies
132 * are NOT run through it: their query filters on status in SQL, with
133 * narrower rules (see the replies section of the callback).
134 *
135 * @param WP_Comment $comment Comment to test.
136 * @return bool
137 */
138 function openstation_my_wordpress_comment_is_visible( $comment ) {
139 if ( '1' === (string) $comment->comment_approved ) {
140 return true;
141 }
142 if ( current_user_can( 'moderate_comments' ) ) {
143 return true;
144 }
145 $author_id = (int) $comment->user_id;
146 return $author_id > 0 && (int) get_current_user_id() === $author_id;
147 }
148
149 /**
150 * Aggregator callback.
151 *
152 * @param WP_REST_Request $request REST request.
153 * @return array|WP_Error
154 */
155 function openstation_my_wordpress_comment_stats_callback( $request ) {
156 global $wpdb;
157 // `\d+` matches 0 and absint() keeps it, so the zero has to be
158 // refused here: get_comment( 0 ) falls back to $GLOBALS['comment'],
159 // which would answer /comment-stats/0 with whatever comment another
160 // plugin happened to leave in the global instead of the documented
161 // 404. Same hazard as get_post( 0 ) below, one level up.
162 $comment_id = (int) $request->get_param( 'id' );
163 $comment = $comment_id > 0 ? get_comment( $comment_id ) : null;
164 if ( ! $comment ) {
165 return new WP_Error(
166 'openstation_comment_not_found',
167 __( 'Comment not found.', 'desktop-mode' ),
168 array( 'status' => 404 )
169 );
170 }
171
172 // Object-level authorization: refuse when the caller can't read
173 // the comment's parent post (OPENSTA-155). Fetched once here and
174 // reused for the parent-post payload below. The explicit zero
175 // check matters: get_post( 0 ) falls back to the global post, so
176 // an orphaned comment would be authorized against whatever post
177 // happened to be global instead of hitting the moderators-only
178 // branch.
179 $post = $comment->comment_post_ID
180 ? get_post( (int) $comment->comment_post_ID )
181 : null;
182 if ( ! openstation_my_wordpress_can_read_comment_post( $post ) ) {
183 return new WP_Error(
184 'openstation_comment_forbidden',
185 __( 'You do not have permission to view this comment.', 'desktop-mode' ),
186 array( 'status' => 403 )
187 );
188 }
189
190 $can_moderate = current_user_can( 'moderate_comments' );
191 $is_approved = '1' === (string) $comment->comment_approved;
192
193 if ( ! openstation_my_wordpress_comment_is_visible( $comment ) ) {
194 return new WP_Error(
195 'openstation_comment_forbidden',
196 __( 'You do not have permission to view this comment.', 'desktop-mode' ),
197 array( 'status' => 403 )
198 );
199 }
200
201 // ----- Comment body ------------------------------------------------
202 /** This filter is documented in wp-includes/comment-template.php */
203 $content_filtered = apply_filters( 'comment_text', $comment->comment_content, $comment, array() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's filter, applied so comment bodies render as they do everywhere else.
204 $body = array(
205 'id' => (int) $comment->comment_ID,
206 'parent' => (int) $comment->comment_parent,
207 'date' => mysql2date( 'c', $comment->comment_date_gmt, false ),
208 'status' => $is_approved
209 ? 'approved'
210 : ( '0' === (string) $comment->comment_approved
211 ? 'pending'
212 : (string) $comment->comment_approved ),
213 'rendered' => (string) $content_filtered,
214 'rendered_raw' => (string) $comment->comment_content,
215 'editLink' => $can_moderate
216 ? esc_url_raw(
217 admin_url(
218 'comment.php?action=editcomment&c=' . $comment->comment_ID
219 )
220 )
221 : '',
222 );
223 if ( $can_moderate ) {
224 $body['type'] = (string) $comment->comment_type;
225 $body['ip'] = (string) $comment->comment_author_IP;
226 $body['userAgent'] = (string) $comment->comment_agent;
227 $body['karma'] = (int) $comment->comment_karma;
228 }
229
230 // ----- Author ------------------------------------------------------
231 $author = array(
232 'name' => (string) $comment->comment_author,
233 'url' => esc_url_raw( (string) $comment->comment_author_url ),
234 'avatarUrl' => (string) get_avatar_url(
235 $comment,
236 array( 'size' => 96 )
237 ),
238 'userId' => (int) $comment->user_id,
239 );
240 if ( $can_moderate ) {
241 $author['email'] = (string) $comment->comment_author_email;
242 }
243 if ( $author['userId'] > 0 ) {
244 $user = get_userdata( $author['userId'] );
245 if ( $user ) {
246 $author['displayName'] = $user->display_name;
247 $author['profileLink'] = get_author_posts_url( $user->ID );
248 }
249 }
250
251 // ----- Parent post -------------------------------------------------
252 $post_payload = null;
253 if ( $post ) {
254 $post_author = $post->post_author > 0
255 ? get_userdata( (int) $post->post_author )
256 : null;
257 $post_payload = array(
258 'id' => (int) $post->ID,
259 'title' => get_the_title( $post ),
260 'link' => (string) get_permalink( $post ),
261 'editLink' => current_user_can( 'edit_post', $post->ID )
262 ? (string) get_edit_post_link( $post->ID, 'raw' )
263 : '',
264 'status' => (string) $post->post_status,
265 'type' => (string) $post->post_type,
266 'date' => mysql2date( 'c', $post->post_date_gmt, false ),
267 'author' => $post_author
268 ? array(
269 'id' => (int) $post_author->ID,
270 'name' => $post_author->display_name,
271 'avatarUrl' => (string) get_avatar_url(
272 $post_author->ID,
273 array( 'size' => 48 )
274 ),
275 )
276 : null,
277 );
278 }
279
280 // ----- Parent comment (if this is a reply) -------------------------
281 // Only the thread above it on the SAME post, and only if its own
282 // status allows. The gates at the top authorized one post and one
283 // comment; `comment_post_ID` and `comment_parent` are independent
284 // columns, and wp_insert_comment() will happily write a parent that
285 // lives on another post, so an excerpt from an unreadable post could
286 // otherwise ride in here on a readable comment.
287 $parent_payload = null;
288 if ( (int) $comment->comment_parent > 0 ) {
289 $parent_comment = get_comment( (int) $comment->comment_parent );
290 if ( $parent_comment
291 && (int) $parent_comment->comment_post_ID === (int) $comment->comment_post_ID
292 && openstation_my_wordpress_comment_is_visible( $parent_comment )
293 ) {
294 $parent_payload = array(
295 'id' => (int) $parent_comment->comment_ID,
296 'authorName' => (string) $parent_comment->comment_author,
297 'date' => mysql2date( 'c', $parent_comment->comment_date_gmt, false ),
298 'excerpt' => wp_trim_words(
299 wp_strip_all_tags( $parent_comment->comment_content ),
300 40
301 ),
302 );
303 }
304 }
305
306 // ----- Replies (direct children) -----------------------------------
307 // Scoped to the authorized post for the same reason as the parent
308 // above: `comment_parent` alone would pull in a comment stored
309 // against a post the caller cannot read. A reply on another post is
310 // not a reply to this thread anyway.
311 //
312 // Static SQL literal — must not go through a %s placeholder, which
313 // would quote it into an adjacent string literal and break the clause.
314 $reply_status_sql = $can_moderate
315 ? "comment_approved IN ( '0', '1' )"
316 : "comment_approved = '1'";
317 $reply_rows = $wpdb->get_results(
318 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $reply_status_sql is a fixed literal chosen above; no user input.
319 $wpdb->prepare(
320 "SELECT comment_ID, comment_author, comment_author_email,
321 comment_date_gmt, comment_content, comment_approved, user_id
322 FROM {$wpdb->comments}
323 WHERE comment_parent = %d
324 AND comment_post_ID = %d
325 AND {$reply_status_sql}
326 ORDER BY comment_date_gmt ASC
327 LIMIT 20",
328 $comment->comment_ID,
329 $comment->comment_post_ID
330 ),
331 ARRAY_A
332 );
333 $replies = array();
334 foreach ( (array) $reply_rows as $row ) {
335 $replies[] = array(
336 'id' => (int) $row['comment_ID'],
337 'authorName' => (string) $row['comment_author'],
338 'avatarUrl' => (string) get_avatar_url(
339 $row['comment_author_email'],
340 array( 'size' => 32 )
341 ),
342 'date' => mysql2date( 'c', (string) $row['comment_date_gmt'], false ),
343 'excerpt' => wp_trim_words(
344 wp_strip_all_tags( (string) $row['comment_content'] ),
345 40
346 ),
347 'status' => '1' === (string) $row['comment_approved']
348 ? 'approved'
349 : (string) $row['comment_approved'],
350 );
351 }
352
353 // ----- Author activity --------------------------------------------
354 // "How busy is this commenter site-wide?" — total approved
355 // comments by this email (or user_id when logged in).
356 $author_total = 0;
357 if ( $author['userId'] > 0 ) {
358 $author_total = (int) $wpdb->get_var(
359 $wpdb->prepare(
360 "SELECT COUNT(*) FROM {$wpdb->comments}
361 WHERE user_id = %d AND comment_approved = '1'",
362 $author['userId']
363 )
364 );
365 } elseif ( ! empty( $comment->comment_author_email ) ) {
366 $author_total = (int) $wpdb->get_var(
367 $wpdb->prepare(
368 "SELECT COUNT(*) FROM {$wpdb->comments}
369 WHERE comment_author_email = %s AND comment_approved = '1'",
370 (string) $comment->comment_author_email
371 )
372 );
373 }
374 $author['totalApprovedComments'] = $author_total;
375
376 $payload = array(
377 'comment' => $body,
378 'author' => $author,
379 'post' => $post_payload,
380 'parent' => $parent_payload,
381 'replies' => $replies,
382 );
383
384 /**
385 * Filter the per-comment dossier payload.
386 *
387 * @param array $payload Stats payload.
388 * @param int $comment_id Comment id.
389 */
390 return apply_filters(
391 'openstation_my_wordpress_comment_stats',
392 $payload,
393 $comment_id
394 );
395 }
396