PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.0.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.0.1
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 / comments-window / rest.php

rest.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.0.1, at includes/comments-window/rest.php

377 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Native Comments Window: REST mutation + helper routes.
4 *
5 * Four endpoints under `desktop-mode/v1`:
6 *
7 * - POST /comments/bulk { ids: int[], action: 'approve'|'unapprove'|'spam'|'unspam'|'trash'|'untrash' }
8 * - POST /comments/reply { parent: int, content: string }
9 * - GET /comments/insights/<email>
10 * - GET /comments/counts
11 *
12 * SECURITY POSTURE
13 * ================
14 *
15 * 1. `permission_callback` — broad cap gate
16 * (`moderate_comments`, `edit_posts`).
17 * 2. Per-target re-validation inside the callback —
18 * `current_user_can( 'edit_comment', $id )` per row.
19 *
20 * @package OpenStation
21 */
22
23 defined( 'ABSPATH' ) || exit;
24
25 /**
26 * Allowed bulk actions, mapped to the function that performs them on a single id.
27 *
28 * Each callback returns true on success, false on a soft failure (the
29 * row is skipped) and throws nothing — the bulk endpoint logs misses
30 * but never aborts the batch on a single bad row.
31 *
32 * @return array<string,callable>
33 */
34 function openstation_comments_window_bulk_action_map() {
35 return array(
36 'approve' => static function ( $id ) {
37 return false !== wp_set_comment_status( $id, 'approve' );
38 },
39 'unapprove' => static function ( $id ) {
40 return false !== wp_set_comment_status( $id, 'hold' );
41 },
42 'spam' => static function ( $id ) {
43 return false !== wp_spam_comment( $id );
44 },
45 'unspam' => static function ( $id ) {
46 return false !== wp_unspam_comment( $id );
47 },
48 'trash' => static function ( $id ) {
49 return false !== wp_trash_comment( $id );
50 },
51 'untrash' => static function ( $id ) {
52 return false !== wp_untrash_comment( $id );
53 },
54 );
55 }
56
57 /**
58 * Register all routes.
59 */
60 function openstation_comments_window_register_rest_routes() {
61 register_rest_route(
62 'desktop-mode/v1',
63 '/comments/bulk',
64 array(
65 'methods' => WP_REST_Server::CREATABLE,
66 'callback' => 'openstation_comments_window_rest_bulk',
67 'permission_callback' => static function () {
68 return current_user_can( 'moderate_comments' );
69 },
70 'args' => array(
71 'ids' => array(
72 'required' => true,
73 'type' => 'array',
74 'items' => array( 'type' => 'integer' ),
75 ),
76 'action' => array(
77 'required' => true,
78 'type' => 'string',
79 'enum' => array_keys( openstation_comments_window_bulk_action_map() ),
80 ),
81 ),
82 )
83 );
84
85 register_rest_route(
86 'desktop-mode/v1',
87 '/comments/reply',
88 array(
89 'methods' => WP_REST_Server::CREATABLE,
90 'callback' => 'openstation_comments_window_rest_reply',
91 'permission_callback' => static function () {
92 return current_user_can( 'edit_posts' );
93 },
94 'args' => array(
95 'parent' => array(
96 'required' => true,
97 'type' => 'integer',
98 ),
99 'content' => array(
100 'required' => true,
101 'type' => 'string',
102 ),
103 ),
104 )
105 );
106
107 register_rest_route(
108 'desktop-mode/v1',
109 '/comments/insights/(?P<email>[^/]+)',
110 array(
111 'methods' => WP_REST_Server::READABLE,
112 'callback' => 'openstation_comments_window_rest_insights',
113 'permission_callback' => static function () {
114 return current_user_can( 'moderate_comments' );
115 },
116 'args' => array(
117 'email' => array(
118 'required' => true,
119 'type' => 'string',
120 ),
121 ),
122 )
123 );
124
125 register_rest_route(
126 'desktop-mode/v1',
127 '/comments/counts',
128 array(
129 'methods' => WP_REST_Server::READABLE,
130 'callback' => 'openstation_comments_window_rest_counts',
131 'permission_callback' => static function () {
132 return current_user_can( 'edit_posts' );
133 },
134 )
135 );
136 }
137 add_action( 'rest_api_init', 'openstation_comments_window_register_rest_routes' );
138
139 /**
140 * Bulk moderation handler.
141 *
142 * @param WP_REST_Request $request Request.
143 * @return WP_REST_Response|WP_Error
144 */
145 function openstation_comments_window_rest_bulk( WP_REST_Request $request ) {
146 $ids = array_values( array_filter( array_map( 'intval', (array) $request['ids'] ) ) );
147 $action = (string) $request['action'];
148 $map = openstation_comments_window_bulk_action_map();
149
150 if ( ! isset( $map[ $action ] ) ) {
151 return new WP_Error(
152 'openstation_comments_invalid_action',
153 __( 'Unknown bulk action.', 'desktop-mode' ),
154 array( 'status' => 400 )
155 );
156 }
157
158 $cb = $map[ $action ];
159 $processed = array();
160 $skipped = array();
161
162 foreach ( $ids as $id ) {
163 if ( ! current_user_can( 'edit_comment', $id ) ) {
164 $skipped[] = $id;
165 continue;
166 }
167 if ( $cb( $id ) ) {
168 $processed[] = $id;
169 } else {
170 $skipped[] = $id;
171 }
172 }
173
174 /**
175 * Fires after a Comments-window bulk action runs.
176 *
177 * @param string $action Action slug.
178 * @param int[] $processed Ids successfully acted on.
179 * @param int[] $skipped Ids skipped (cap fail or soft error).
180 */
181 do_action(
182 'openstation_comments_window_after_bulk',
183 $action,
184 $processed,
185 $skipped
186 );
187
188 return new WP_REST_Response(
189 array(
190 'action' => $action,
191 'processed' => $processed,
192 'skipped' => $skipped,
193 'counts' => openstation_comments_window_counts(),
194 ),
195 200
196 );
197 }
198
199 /**
200 * Inline-reply handler. Wraps `wp_new_comment` with sane defaults so
201 * the client only needs `{ parent, content }`.
202 *
203 * @param WP_REST_Request $request Request.
204 * @return WP_REST_Response|WP_Error
205 */
206 function openstation_comments_window_rest_reply( WP_REST_Request $request ) {
207 $parent_id = (int) $request['parent'];
208 $content = (string) $request['content'];
209
210 $parent = get_comment( $parent_id );
211 if ( ! $parent instanceof WP_Comment ) {
212 return new WP_Error(
213 'openstation_comments_no_parent',
214 __( 'Parent comment not found.', 'desktop-mode' ),
215 array( 'status' => 404 )
216 );
217 }
218
219 // Per-target re-validation: mirror core's wp_ajax_replyto_comment gate,
220 // which requires edit_post on the comment's post.
221 $post = get_post( (int) $parent->comment_post_ID );
222 if ( ! $post instanceof WP_Post || ! current_user_can( 'edit_post', $post->ID ) ) {
223 return new WP_Error(
224 'openstation_comments_forbidden',
225 __( 'You are not allowed to reply to comments on this post.', 'desktop-mode' ),
226 array( 'status' => 403 )
227 );
228 }
229
230 if ( '' === trim( wp_strip_all_tags( $content ) ) ) {
231 return new WP_Error(
232 'openstation_comments_empty_reply',
233 __( 'Reply cannot be empty.', 'desktop-mode' ),
234 array( 'status' => 400 )
235 );
236 }
237
238 $user = wp_get_current_user();
239 if ( ! $user || ! $user->ID ) {
240 return new WP_Error(
241 'openstation_comments_unauthenticated',
242 __( 'You must be logged in to reply.', 'desktop-mode' ),
243 array( 'status' => 401 )
244 );
245 }
246
247 $comment_data = array(
248 'comment_post_ID' => (int) $parent->comment_post_ID,
249 'comment_parent' => $parent_id,
250 'user_id' => (int) $user->ID,
251 'comment_author' => (string) $user->display_name,
252 'comment_author_email' => (string) $user->user_email,
253 'comment_author_url' => (string) $user->user_url,
254 'comment_content' => $content,
255 'comment_approved' => 1,
256 'comment_type' => 'comment',
257 );
258
259 $new_id = wp_new_comment( wp_slash( $comment_data ), true );
260 if ( is_wp_error( $new_id ) ) {
261 return $new_id;
262 }
263
264 $new = get_comment( $new_id );
265 return new WP_REST_Response(
266 array(
267 'id' => (int) $new_id,
268 'parent' => $parent_id,
269 'content' => $new ? (string) $new->comment_content : $content,
270 'date_gmt' => $new ? (string) $new->comment_date_gmt : '',
271 'author' => $user->display_name,
272 'avatarUrl' => (string) get_avatar_url( (int) $user->ID, array( 'size' => 96 ) ),
273 ),
274 201
275 );
276 }
277
278 /**
279 * Author insights endpoint — drives the side drawer.
280 *
281 * Returns total/approved/pending/spam counts, oldest/newest comment
282 * timestamps, the linked user id (if the email matches a registered
283 * user), and a 0–100 reliability score.
284 *
285 * @param WP_REST_Request $request Request.
286 * @return WP_REST_Response|WP_Error
287 */
288 function openstation_comments_window_rest_insights( WP_REST_Request $request ) {
289 $email = strtolower( urldecode( (string) $request['email'] ) );
290 if ( '' === $email || ! is_email( $email ) ) {
291 return new WP_Error(
292 'openstation_comments_invalid_email',
293 __( 'Invalid author email.', 'desktop-mode' ),
294 array( 'status' => 400 )
295 );
296 }
297
298 $counts_by_status = array();
299 foreach ( array( 'approve', 'hold', 'spam', 'trash' ) as $status ) {
300 $counts_by_status[ $status ] = (int) get_comments(
301 array(
302 'author_email' => $email,
303 'status' => $status,
304 'count' => true,
305 )
306 );
307 }
308 $total = array_sum( $counts_by_status );
309
310 // Sample the oldest + newest record without loading every row.
311 $oldest = get_comments(
312 array(
313 'author_email' => $email,
314 'status' => 'all',
315 'orderby' => 'comment_date_gmt',
316 'order' => 'ASC',
317 'number' => 1,
318 )
319 );
320 $newest = get_comments(
321 array(
322 'author_email' => $email,
323 'status' => 'all',
324 'orderby' => 'comment_date_gmt',
325 'order' => 'DESC',
326 'number' => 1,
327 )
328 );
329
330 $user = get_user_by( 'email', $email );
331 $reliability = 100;
332 if ( $total > 0 ) {
333 $bad = $counts_by_status['spam'] + $counts_by_status['trash'];
334 $reliability = (int) round( max( 0, min( 100, 100 - ( $bad / $total ) * 100 ) ) );
335 }
336
337 return new WP_REST_Response(
338 array(
339 'email' => $email,
340 'total' => $total,
341 'counts' => $counts_by_status,
342 'oldest' => isset( $oldest[0] ) ? (string) $oldest[0]->comment_date_gmt : null,
343 'newest' => isset( $newest[0] ) ? (string) $newest[0]->comment_date_gmt : null,
344 'userId' => $user ? (int) $user->ID : 0,
345 'userName' => $user ? (string) $user->display_name : '',
346 'reliability' => $reliability,
347 'avatarUrl' => (string) get_avatar_url( $email, array( 'size' => 96 ) ),
348 ),
349 200
350 );
351 }
352
353 /**
354 * Per-status counts. Used by the dock badge + the "N new" pill.
355 *
356 * @return WP_REST_Response
357 */
358 function openstation_comments_window_rest_counts() {
359 return new WP_REST_Response( openstation_comments_window_counts(), 200 );
360 }
361
362 /**
363 * Internal helper — current comment counts as a flat array.
364 *
365 * @return array<string,int>
366 */
367 function openstation_comments_window_counts() {
368 $counts = wp_count_comments();
369 return array(
370 'pending' => (int) $counts->moderated,
371 'approved' => (int) $counts->approved,
372 'spam' => (int) $counts->spam,
373 'trash' => (int) $counts->trash,
374 'total' => (int) $counts->total_comments,
375 );
376 }
377