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 +159 -25 1.1.4 → 1.1.12 View file →
@@ -7,12 +7,32 @@
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 38 * @package OpenStation
@@ -30,9 +50,14 @@
30 50 array(
31 51 'methods' => WP_REST_Server::READABLE,
32 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,
@@ -45,8 +70,84 @@
45 70 }
46 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
@@ -52,10 +153,15 @@
52 153 * @return array|WP_Error
53 154 */
54 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 166 'openstation_comment_not_found',
61 167 __( 'Comment not found.', 'desktop-mode' ),
@@ -62,15 +168,30 @@
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 195 'openstation_comment_forbidden',
75 196 __( 'You do not have permission to view this comment.', 'desktop-mode' ),
76 197 array( 'status' => 403 )
@@ -107,9 +228,9 @@
107 228 }
108 229
109 230 // ----- Author ------------------------------------------------------
110 231 $author = array(
111 - 'name' => (string) $comment->comment_author,
232 + 'name' => openstation_plain_text_title( $comment->comment_author ),
112 233 'url' => esc_url_raw( (string) $comment->comment_author_url ),
113 234 'avatarUrl' => (string) get_avatar_url(
114 235 $comment,
115 236 array( 'size' => 96 )
@@ -121,15 +242,14 @@
121 242 }
122 243 if ( $author['userId'] > 0 ) {
123 244 $user = get_userdata( $author['userId'] );
124 245 if ( $user ) {
125 - $author['displayName'] = $user->display_name;
246 + $author['displayName'] = openstation_plain_text_title( $user->display_name );
126 247 $author['profileLink'] = get_author_posts_url( $user->ID );
127 248 }
128 249 }
129 250
130 251 // ----- Parent post -------------------------------------------------
131 - $post = get_post( (int) $comment->comment_post_ID );
132 252 $post_payload = null;
133 253 if ( $post ) {
134 254 $post_author = $post->post_author > 0
135 255 ? get_userdata( (int) $post->post_author )
@@ -135,9 +255,9 @@
135 255 ? get_userdata( (int) $post->post_author )
136 256 : null;
137 257 $post_payload = array(
138 258 'id' => (int) $post->ID,
139 - 'title' => get_the_title( $post ),
259 + 'title' => openstation_plain_text_title( get_the_title( $post ) ),
140 260 'link' => (string) get_permalink( $post ),
141 261 'editLink' => current_user_can( 'edit_post', $post->ID )
142 262 ? (string) get_edit_post_link( $post->ID, 'raw' )
143 263 : '',
@@ -146,9 +266,9 @@
146 266 'date' => mysql2date( 'c', $post->post_date_gmt, false ),
147 267 'author' => $post_author
148 268 ? array(
149 269 'id' => (int) $post_author->ID,
150 - 'name' => $post_author->display_name,
270 + 'name' => openstation_plain_text_title( $post_author->display_name ),
151 271 'avatarUrl' => (string) get_avatar_url(
152 272 $post_author->ID,
153 273 array( 'size' => 48 )
154 274 ),
@@ -157,19 +277,27 @@
157 277 );
158 278 }
159 279
160 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.
161 287 $parent_payload = null;
162 288 if ( (int) $comment->comment_parent > 0 ) {
163 289 $parent_comment = get_comment( (int) $comment->comment_parent );
164 - 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 + ) {
165 294 $parent_payload = array(
166 295 'id' => (int) $parent_comment->comment_ID,
167 - 'authorName' => (string) $parent_comment->comment_author,
296 + 'authorName' => openstation_plain_text_title( $parent_comment->comment_author ),
168 297 'date' => mysql2date( 'c', $parent_comment->comment_date_gmt, false ),
169 - 'excerpt' => wp_trim_words(
170 - wp_strip_all_tags( $parent_comment->comment_content ),
171 - 40
298 + 'excerpt' => openstation_plain_text_title(
299 + wp_trim_words( openstation_strip_all_tags( $parent_comment->comment_content ), 40 )
172 300 ),
173 301 );
174 302 }
175 303 }
@@ -174,8 +302,13 @@
174 302 }
175 303 }
176 304
177 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 + //
178 311 // Static SQL literal — must not go through a %s placeholder, which
179 312 // would quote it into an adjacent string literal and break the clause.
180 313 $reply_status_sql = $can_moderate
181 314 ? "comment_approved IN ( '0', '1' )"
@@ -186,12 +319,14 @@
186 319 "SELECT comment_ID, comment_author, comment_author_email,
187 320 comment_date_gmt, comment_content, comment_approved, user_id
188 321 FROM {$wpdb->comments}
189 322 WHERE comment_parent = %d
323 + AND comment_post_ID = %d
190 324 AND {$reply_status_sql}
191 325 ORDER BY comment_date_gmt ASC
192 326 LIMIT 20",
193 - $comment->comment_ID
327 + $comment->comment_ID,
328 + $comment->comment_post_ID
194 329 ),
195 330 ARRAY_A
196 331 );
197 332 $replies = array();
@@ -197,17 +332,16 @@
197 332 $replies = array();
198 333 foreach ( (array) $reply_rows as $row ) {
199 334 $replies[] = array(
200 335 'id' => (int) $row['comment_ID'],
201 - 'authorName' => (string) $row['comment_author'],
336 + 'authorName' => openstation_plain_text_title( $row['comment_author'] ),
202 337 'avatarUrl' => (string) get_avatar_url(
203 338 $row['comment_author_email'],
204 339 array( 'size' => 32 )
205 340 ),
206 341 'date' => mysql2date( 'c', (string) $row['comment_date_gmt'], false ),
207 - 'excerpt' => wp_trim_words(
208 - wp_strip_all_tags( (string) $row['comment_content'] ),
209 - 40
342 + 'excerpt' => openstation_plain_text_title(
343 + wp_trim_words( openstation_strip_all_tags( (string) $row['comment_content'] ), 40 )
210 344 ),
211 345 'status' => '1' === (string) $row['comment_approved']
212 346 ? 'approved'
213 347 : (string) $row['comment_approved'],