PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.12
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.12
1.1.12 1.1.11 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 All 36 releases
← All changes | includes/my-wordpress/comment-stats.php +186 -51 0.9.8 → 1.1.12 View file →
@@ -1,7 +1,7 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — My WordPress: per-comment dossier endpoint.
3 + * OpenStation — My WordPress: per-comment dossier endpoint.
4 4 *
5 5 * `GET /desktop-mode/v1/comment-stats/<id>` returns the rendered
6 6 * comment + author + parent post + thread context + reply tree +
7 7 * a count of how active the author has been across the site. Powers
@@ -7,16 +7,36 @@
7 7 * a count of how active the author has been across the site. Powers
8 8 * the right preview pane in the My WordPress folder when a comment
9 9 * is selected.
10 10 *
11 - * Permissions:
12 - * - The comment must be readable by the current user (approved,
13 - * OR the user can `moderate_comments`, OR they're the comment
14 - * author).
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.
15 35 * - Author email / IP / user-agent only ship to viewers with
16 36 * `moderate_comments`.
17 37 *
18 - * @package WPDesktopMode
38 + * @package OpenStation
19 39 */
20 40
21 41 defined( 'ABSPATH' ) || exit;
22 42
@@ -22,17 +42,22 @@
22 42
23 43 /**
24 44 * Register the route.
25 45 */
26 -function desktop_mode_my_wordpress_register_comment_stats_route() {
46 +function openstation_my_wordpress_register_comment_stats_route() {
27 47 register_rest_route(
28 48 'desktop-mode/v1',
29 49 '/comment-stats/(?P<id>\d+)',
30 50 array(
31 51 'methods' => WP_REST_Server::READABLE,
32 - 'callback' => 'desktop_mode_my_wordpress_comment_stats_callback',
52 + 'callback' => 'openstation_my_wordpress_comment_stats_callback',
33 53 'permission_callback' => static function () {
34 - return is_user_logged_in();
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();
35 60 },
36 61 'args' => array(
37 62 'id' => array(
38 63 'required' => true,
@@ -42,37 +67,133 @@
42 67 ),
43 68 )
44 69 );
45 70 }
46 -add_action( 'rest_api_init', 'desktop_mode_my_wordpress_register_comment_stats_route' );
71 +add_action( 'rest_api_init', 'openstation_my_wordpress_register_comment_stats_route' );
47 72
48 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 +/**
49 150 * Aggregator callback.
50 151 *
51 152 * @param WP_REST_Request $request REST request.
52 153 * @return array|WP_Error
53 154 */
54 -function desktop_mode_my_wordpress_comment_stats_callback( $request ) {
155 +function openstation_my_wordpress_comment_stats_callback( $request ) {
55 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.
56 162 $comment_id = (int) $request->get_param( 'id' );
57 - $comment = get_comment( $comment_id );
163 + $comment = $comment_id > 0 ? get_comment( $comment_id ) : null;
58 164 if ( ! $comment ) {
59 165 return new WP_Error(
60 - 'desktop_mode_comment_not_found',
166 + 'openstation_comment_not_found',
61 167 __( 'Comment not found.', 'desktop-mode' ),
62 168 array( 'status' => 404 )
63 169 );
64 170 }
65 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 +
66 190 $can_moderate = current_user_can( 'moderate_comments' );
67 191 $is_approved = '1' === (string) $comment->comment_approved;
68 - $is_self = is_user_logged_in()
69 - && (int) get_current_user_id() === (int) $comment->user_id
70 - && (int) $comment->user_id > 0;
71 192
72 - if ( ! $is_approved && ! $can_moderate && ! $is_self ) {
193 + if ( ! openstation_my_wordpress_comment_is_visible( $comment ) ) {
73 194 return new WP_Error(
74 - 'desktop_mode_comment_forbidden',
195 + 'openstation_comment_forbidden',
75 196 __( 'You do not have permission to view this comment.', 'desktop-mode' ),
76 197 array( 'status' => 403 )
77 198 );
78 199 }
@@ -77,21 +198,22 @@
77 198 );
78 199 }
79 200
80 201 // ----- Comment body ------------------------------------------------
81 - $content_filtered = apply_filters( 'comment_text', $comment->comment_content, $comment, array() );
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.
82 204 $body = array(
83 - 'id' => (int) $comment->comment_ID,
84 - 'parent' => (int) $comment->comment_parent,
85 - 'date' => mysql2date( 'c', $comment->comment_date_gmt, false ),
86 - 'status' => $is_approved
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
87 209 ? 'approved'
88 210 : ( '0' === (string) $comment->comment_approved
89 211 ? 'pending'
90 212 : (string) $comment->comment_approved ),
91 - 'rendered' => (string) $content_filtered,
213 + 'rendered' => (string) $content_filtered,
92 214 'rendered_raw' => (string) $comment->comment_content,
93 - 'editLink' => $can_moderate
215 + 'editLink' => $can_moderate
94 216 ? esc_url_raw(
95 217 admin_url(
96 218 'comment.php?action=editcomment&c=' . $comment->comment_ID
97 219 )
@@ -106,9 +228,9 @@
106 228 }
107 229
108 230 // ----- Author ------------------------------------------------------
109 231 $author = array(
110 - 'name' => (string) $comment->comment_author,
232 + 'name' => openstation_plain_text_title( $comment->comment_author ),
111 233 'url' => esc_url_raw( (string) $comment->comment_author_url ),
112 234 'avatarUrl' => (string) get_avatar_url(
113 235 $comment,
114 236 array( 'size' => 96 )
@@ -120,34 +242,33 @@
120 242 }
121 243 if ( $author['userId'] > 0 ) {
122 244 $user = get_userdata( $author['userId'] );
123 245 if ( $user ) {
124 - $author['displayName'] = $user->display_name;
125 - $author['profileLink'] = get_author_posts_url( $user->ID );
246 + $author['displayName'] = openstation_plain_text_title( $user->display_name );
247 + $author['profileLink'] = get_author_posts_url( $user->ID );
126 248 }
127 249 }
128 250
129 251 // ----- Parent post -------------------------------------------------
130 - $post = get_post( (int) $comment->comment_post_ID );
131 252 $post_payload = null;
132 253 if ( $post ) {
133 - $post_author = $post->post_author > 0
254 + $post_author = $post->post_author > 0
134 255 ? get_userdata( (int) $post->post_author )
135 256 : null;
136 257 $post_payload = array(
137 - 'id' => (int) $post->ID,
138 - 'title' => get_the_title( $post ),
139 - 'link' => (string) get_permalink( $post ),
258 + 'id' => (int) $post->ID,
259 + 'title' => openstation_plain_text_title( get_the_title( $post ) ),
260 + 'link' => (string) get_permalink( $post ),
140 261 'editLink' => current_user_can( 'edit_post', $post->ID )
141 262 ? (string) get_edit_post_link( $post->ID, 'raw' )
142 263 : '',
143 - 'status' => (string) $post->post_status,
144 - 'type' => (string) $post->post_type,
145 - 'date' => mysql2date( 'c', $post->post_date_gmt, false ),
146 - 'author' => $post_author
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
147 268 ? array(
148 269 'id' => (int) $post_author->ID,
149 - 'name' => $post_author->display_name,
270 + 'name' => openstation_plain_text_title( $post_author->display_name ),
150 271 'avatarUrl' => (string) get_avatar_url(
151 272 $post_author->ID,
152 273 array( 'size' => 48 )
153 274 ),
@@ -156,19 +277,27 @@
156 277 );
157 278 }
158 279
159 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.
160 287 $parent_payload = null;
161 288 if ( (int) $comment->comment_parent > 0 ) {
162 289 $parent_comment = get_comment( (int) $comment->comment_parent );
163 - if ( $parent_comment ) {
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 + ) {
164 294 $parent_payload = array(
165 - 'id' => (int) $parent_comment->comment_ID,
166 - 'authorName' => (string) $parent_comment->comment_author,
167 - 'date' => mysql2date( 'c', $parent_comment->comment_date_gmt, false ),
168 - 'excerpt' => wp_trim_words(
169 - wp_strip_all_tags( $parent_comment->comment_content ),
170 - 40
295 + 'id' => (int) $parent_comment->comment_ID,
296 + 'authorName' => openstation_plain_text_title( $parent_comment->comment_author ),
297 + 'date' => mysql2date( 'c', $parent_comment->comment_date_gmt, false ),
298 + 'excerpt' => openstation_plain_text_title(
299 + wp_trim_words( openstation_strip_all_tags( $parent_comment->comment_content ), 40 )
171 300 ),
172 301 );
173 302 }
174 303 }
@@ -173,8 +302,13 @@
173 302 }
174 303 }
175 304
176 305 // ----- Replies (direct children) -----------------------------------
306 + // Scoped to the authorized post for the same reason as the parent
307 + // above: `comment_parent` alone would pull in a comment stored
308 + // against a post the caller cannot read. A reply on another post is
309 + // not a reply to this thread anyway.
310 + //
177 311 // Static SQL literal — must not go through a %s placeholder, which
178 312 // would quote it into an adjacent string literal and break the clause.
179 313 $reply_status_sql = $can_moderate
180 314 ? "comment_approved IN ( '0', '1' )"
@@ -185,12 +319,14 @@
185 319 "SELECT comment_ID, comment_author, comment_author_email,
186 320 comment_date_gmt, comment_content, comment_approved, user_id
187 321 FROM {$wpdb->comments}
188 322 WHERE comment_parent = %d
323 + AND comment_post_ID = %d
189 324 AND {$reply_status_sql}
190 325 ORDER BY comment_date_gmt ASC
191 326 LIMIT 20",
192 - $comment->comment_ID
327 + $comment->comment_ID,
328 + $comment->comment_post_ID
193 329 ),
194 330 ARRAY_A
195 331 );
196 332 $replies = array();
@@ -196,17 +332,16 @@
196 332 $replies = array();
197 333 foreach ( (array) $reply_rows as $row ) {
198 334 $replies[] = array(
199 335 'id' => (int) $row['comment_ID'],
200 - 'authorName' => (string) $row['comment_author'],
336 + 'authorName' => openstation_plain_text_title( $row['comment_author'] ),
201 337 'avatarUrl' => (string) get_avatar_url(
202 338 $row['comment_author_email'],
203 339 array( 'size' => 32 )
204 340 ),
205 341 'date' => mysql2date( 'c', (string) $row['comment_date_gmt'], false ),
206 - 'excerpt' => wp_trim_words(
207 - wp_strip_all_tags( (string) $row['comment_content'] ),
208 - 40
342 + 'excerpt' => openstation_plain_text_title(
343 + wp_trim_words( openstation_strip_all_tags( (string) $row['comment_content'] ), 40 )
209 344 ),
210 345 'status' => '1' === (string) $row['comment_approved']
211 346 ? 'approved'
212 347 : (string) $row['comment_approved'],
@@ -250,9 +385,9 @@
250 385 * @param array $payload Stats payload.
251 386 * @param int $comment_id Comment id.
252 387 */
253 388 return apply_filters(
254 - 'desktop_mode_my_wordpress_comment_stats',
389 + 'openstation_my_wordpress_comment_stats',
255 390 $payload,
256 391 $comment_id
257 392 );
258 393 }