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 / apps / comments / parts / rest.php

rest.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.10, at apps/comments/parts/rest.php

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