PluginProbe
UpStream: a Project Management Plugin for WordPress / 2.1.0
UpStream: a Project Management Plugin for WordPress v2.1.0
trunk 1.39.0 1.39.1 1.39.2 1.39.3 2.0.7 2.1.0
upstream / includes / class-comments.php

class-comments.php in UpStream: a Project Management Plugin for WordPress 2.1.0, at includes/class-comments.php

1,409 lines 42.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * A controller handling incoming requests regarding comments on UpStream items.
4 *
5 * @package UpStream
6 */
7
8 namespace UpStream;
9
10 // Prevent direct access.
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 use UpStream\Traits\Singleton;
16
17 /**
18 * This class will act as a controller handling incoming requests regarding comments on UpStream items.
19 *
20 * @since 1.13.0
21 */
22 class Comments {
23 use Singleton;
24
25 /**
26 * The current full namespace.
27 *
28 * @since 1.13.0
29 * @access private
30 * @static
31 *
32 * @var string $namespace
33 */
34 private static $namespace;
35
36 /**
37 * Class constructor.
38 *
39 * @since 1.13.0
40 */
41 public function __construct() {
42 self::$namespace = get_class(
43 empty( self::$instance )
44 ? $this
45 : self::$instance
46 );
47
48 $this->attach_hooks();
49
50 self::remove_comment_type();
51 }
52
53 /**
54 * Attach all relevant actions to handle comments.
55 *
56 * @since 1.13.0
57 * @access private
58 */
59 private function attach_hooks() {
60 add_action( 'wp_ajax_upstream:project.add_comment', array( self::$namespace, 'store_comment' ) );
61 add_action( 'wp_ajax_upstream:project.add_comment_reply', array( self::$namespace, 'store_comment_reply' ) );
62 add_action( 'wp_ajax_upstream:project.trash_comment', array( self::$namespace, 'trash_comment' ) );
63 add_action( 'wp_ajax_upstream:project.unapprove_comment', array( self::$namespace, 'unapprove_comment' ) );
64 add_action( 'wp_ajax_upstream:project.approve_comment', array( self::$namespace, 'approve_comment' ) );
65 add_action( 'wp_ajax_upstream:project.fetch_comments', array( self::$namespace, 'fetch_comments' ) );
66
67 add_filter( 'comment_notification_subject', array( self::$namespace, 'define_notification_header' ), 10, 2 );
68 add_filter( 'comment_notification_recipients', array( self::$namespace, 'define_notification_recipients' ), 10, 2 );
69 add_filter( 'comment_notification_text', array( self::$namespace, 'add_item_title_to_notification' ), 10, 2 );
70
71 add_filter( 'upstream_allowed_tags_in_comments', array( self::$namespace, 'filter_allowed_tags' ) );
72 add_filter(
73 'comment_notification_headers',
74 array( self::$namespace, 'filter_comment_notification_headers' ),
75 10,
76 2
77 );
78
79 add_filter( 'comment_notification_text', array( self::$namespace, 'filter_comment_notification_text' ), 10, 2 );
80 }
81
82 /**
83 * Empties the comment_type="comment" column from UpStream comments.
84 *
85 * @since 1.16.3
86 * @static
87 */
88 public static function remove_comment_type() {
89 $did_remove_comments_type = (bool) get_option( 'upstream:remove_comments_type' );
90
91 if ( ! $did_remove_comments_type ) {
92 global $wpdb;
93
94 $wpdb->query(
95 sprintf(
96 'UPDATE `%s` AS `comment`
97 LEFT JOIN `%s` AS `post`
98 ON `post`.`ID` = `comment`.`comment_post_ID`
99 SET `comment_type` = ""
100 WHERE `comment_type` = "comment"
101 AND `post_type` = "project"',
102 $wpdb->prefix . 'comments',
103 $wpdb->prefix . 'posts'
104 )
105 );
106
107 update_option( 'upstream:remove_comments_type', 1 );
108 }
109 }
110
111 /**
112 * Filter allowed tags.
113 *
114 * @param array $allowed_tags Allowed tags.
115 *
116 * @return array
117 */
118 public static function filter_allowed_tags( $allowed_tags ) {
119 global $allowedtags;
120
121 // Add default allowed tags.
122 $allowed_tags = array_merge( $allowed_tags, $allowedtags );
123
124 // Add basic tags.
125 if ( ! array_key_exists( 'p', $allowed_tags ) ) {
126 $allowed_tags['p'] = array(
127 'class' => true,
128 'id' => true,
129 );
130 }
131
132 if ( ! array_key_exists( 'br', $allowed_tags ) ) {
133 $allowed_tags['br'] = array();
134 }
135
136 if ( ! array_key_exists( 'strong', $allowed_tags ) ) {
137 $allowed_tags['strong'] = array(
138 'class' => true,
139 'id' => true,
140 );
141 }
142
143 if ( ! array_key_exists( 'em', $allowed_tags ) ) {
144 $allowed_tags['em'] = array(
145 'class' => true,
146 'id' => true,
147 );
148 }
149
150 if ( ! array_key_exists( 'span', $allowed_tags ) ) {
151 $allowed_tags['span'] = array(
152 'class' => true,
153 'id' => true,
154 'style' => true,
155 );
156 }
157
158 if ( ! array_key_exists( 'del', $allowed_tags ) ) {
159 $allowed_tags['del'] = array(
160 'class' => true,
161 'id' => true,
162 );
163 }
164
165 if ( ! array_key_exists( 'ul', $allowed_tags ) ) {
166 $allowed_tags['ul'] = array(
167 'class' => true,
168 'id' => true,
169 );
170 }
171
172 if ( ! array_key_exists( 'ol', $allowed_tags ) ) {
173 $allowed_tags['ol'] = array(
174 'class' => true,
175 'id' => true,
176 );
177 }
178
179 if ( ! array_key_exists( 'li', $allowed_tags ) ) {
180 $allowed_tags['li'] = array(
181 'class' => true,
182 'id' => true,
183 );
184 }
185
186 if ( ! array_key_exists( 'a', $allowed_tags ) ) {
187 $allowed_tags['a'] = array(
188 'class' => true,
189 'id' => true,
190 'href' => true,
191 'charset' => true,
192 'name' => true,
193 'rel' => true,
194 'target' => true,
195 'type' => true,
196 );
197 } else {
198 $allowed_tags['a']['class'] = true;
199 $allowed_tags['a']['id'] = true;
200 $allowed_tags['a']['href'] = true;
201 $allowed_tags['a']['charset'] = true;
202 $allowed_tags['a']['name'] = true;
203 $allowed_tags['a']['rel'] = true;
204 $allowed_tags['a']['target'] = true;
205 $allowed_tags['a']['type'] = true;
206 }
207
208 // If the current can't post images, we return current supported tags.
209 if ( ! current_user_can( 'upstream_comment_images' ) ) {
210 return $allowed_tags;
211 }
212
213 // The user can post images, so let's allow the img tag.
214 if ( ! is_array( $allowed_tags ) ) {
215 $allowed_tags = array();
216 }
217
218 $allowed_tags['img'] = array(
219 'class' => true,
220 'src' => true,
221 'alt' => true,
222 'width' => true,
223 'height' => true,
224 );
225
226 return $allowed_tags;
227 }
228
229 /**
230 * AJAX endpoint that stores a new comment.
231 *
232 * @throws \Exception Set the error message.
233 * @since 1.13.0
234 * @static
235 */
236 public static function store_comment() {
237 header( 'Content-Type: application/json' );
238
239 $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
240 $response = array(
241 'success' => false,
242 'error' => null,
243 );
244
245 try {
246 // Check if the request payload is potentially invalid.
247 if (
248 ! defined( 'DOING_AJAX' )
249 || ! DOING_AJAX
250 || empty( $post_data )
251 || ! isset( $post_data['nonce'] )
252 || ! isset( $post_data['project_id'] )
253 || ! isset( $post_data['item_type'] )
254 || ! self::is_item_type_valid( sanitize_text_field( $post_data['item_type'] ) )
255 || ! isset( $post_data['content'] )
256 ) {
257 throw new \Exception( __( 'Invalid request.', 'upstream' ) );
258 }
259
260 // Prepare data to verify nonce.
261 $comment_target_item_type = strtolower( sanitize_text_field( $post_data['item_type'] ) );
262 if ( 'project' !== $comment_target_item_type ) {
263 if (
264 ! isset( $post_data['item_id'] )
265 || empty( $post_data['item_id'] )
266 ) {
267 throw new \Exception( __( 'Invalid item.', 'upstream' ) );
268 }
269
270 // non-numeric id.
271 $item_id = sanitize_text_field( $post_data['item_id'] );
272
273 $nonce_identifier = 'upstream:project.' . $comment_target_item_type . 's.add_comment';
274 } else {
275 $item_id = absint( $post_data['project_id'] );
276 $nonce_identifier = 'upstream:project.add_comment';
277 }
278
279 // Verify nonce.
280 if ( ! check_ajax_referer( $nonce_identifier, 'nonce', false ) ) {
281 throw new \Exception( __( 'Invalid nonce.', 'upstream' ) );
282 }
283
284 // Check if the project exists.
285 $project_id = absint( $post_data['project_id'] );
286 if ( $project_id <= 0 ) {
287 throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
288 }
289
290 // Check if commenting is disabled on the given project.
291 if ( upstream_are_comments_disabled( $project_id ) ) {
292 throw new \Exception( __( 'Commenting is disabled on this project.', 'upstream' ) );
293 }
294
295 // Check if the user has enough permissions to insert a new comment.
296 if ( ! upstream_can_access_field( 'publish_project_discussion', $comment_target_item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_EDIT, true ) ) {
297 throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
298 }
299
300 $user_id = get_current_user_id();
301
302 $comment_content = stripslashes( wp_kses_post( $post_data['content'] ) );
303
304 $item_title = isset( $post_data['item_title'] ) ? sanitize_text_field( $post_data['item_title'] ) : '';
305
306 $comment = new Comment( $comment_content, $project_id, $user_id );
307 $server = isset( $_SERVER ) ? wp_unslash( $_SERVER ) : array();
308
309 $comment->created_by->ip = preg_replace( '/[^0-9a-fA-F:., ]/', '', sanitize_text_field( $server['REMOTE_ADDR'] ) );
310 $comment->created_by->agent = isset( $server['HTTP_USER_AGENT'] ) ? sanitize_text_field( $server['HTTP_USER_AGENT'] ) : null;
311
312 $comment->save();
313
314 update_comment_meta( $comment->id, 'type', $comment_target_item_type );
315
316 if ( 'project' !== $comment_target_item_type ) {
317 update_comment_meta( $comment->id, 'id', $item_id );
318 // We store the item title here because of the project's data structure.
319 // It is faster to retrieve from metadata then seek item by item from a project.
320 update_comment_meta( $comment->id, 'title', $item_title );
321 }
322
323 wp_new_comment_notify_moderator( $comment->id );
324 wp_notify_postauthor( $comment->id );
325
326 $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
327
328 $response['comment_html'] = stripslashes( $comment->render( true, $use_admin_layout ) );
329
330 $response['success'] = true;
331 } catch ( \Exception $e ) {
332 $response['error'] = $e->getMessage();
333 }
334
335 wp_send_json( $response );
336 }
337
338
339 /**
340 * Check if the item type is valid.
341 *
342 * @since 1.13.0
343 * @static
344 *
345 * @param string $item_type Value to be validated.
346 * @throws \Exception Set error message.
347 *
348 * @return bool
349 */
350 public static function is_item_type_valid( $item_type ) {
351 $item_types = array( 'project', 'milestone', 'task', 'bug', 'file' );
352
353 return in_array( $item_type, $item_types );
354 }
355
356 /**
357 * AJAX endpoint that adds a new comment reply.
358 *
359 * @throws \Exception Set the error message.
360 * @since 1.13.0
361 * @static
362 */
363 public static function store_comment_reply() {
364 header( 'Content-Type: application/json' );
365
366 $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
367 $server = isset( $_SERVER ) ? wp_unslash( $_SERVER ) : array();
368 $response = array(
369 'success' => false,
370 'error' => null,
371 );
372
373 try {
374 // Check if the request payload is potentially invalid.
375 if (
376 ! defined( 'DOING_AJAX' )
377 || ! DOING_AJAX
378 || empty( $post_data )
379 || ! isset( $post_data['nonce'] )
380 || ! isset( $post_data['project_id'] )
381 || ! isset( $post_data['item_type'] )
382 || ! self::is_item_type_valid( sanitize_text_field( $post_data['item_type'] ) )
383 || ! isset( $post_data['content'] )
384 || ! isset( $post_data['parent_id'] )
385 || ! is_numeric( sanitize_text_field( $post_data['parent_id'] ) )
386 || ! check_ajax_referer( 'upstream:project.add_comment_reply:' . sanitize_text_field( $post_data['parent_id'] ), 'nonce', false )
387 ) {
388 throw new \Exception( __( 'Invalid request.', 'upstream' ) );
389 }
390
391 // could be alnum ID.
392 $item_id = sanitize_text_field( $post_data['item_id'] );
393
394 // Check if the project exists.
395 $project_id = absint( $post_data['project_id'] );
396 if ( $project_id <= 0 ) {
397 throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
398 }
399
400 $comment_target_item_type = strtolower( sanitize_text_field( $post_data['item_type'] ) );
401 if ( 'project' !== $comment_target_item_type ) {
402 if (
403 ! isset( $post_data['item_id'] )
404 || empty( $post_data['item_id'] )
405 ) {
406 throw new \Exception( __( 'Invalid request.', 'upstream' ) );
407 }
408 } else {
409 $item_id = $project_id;
410 }
411
412 // Check if the user has enough permissions to insert a new comment.
413 if ( ! upstream_can_access_field( 'publish_project_discussion', $comment_target_item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_EDIT, true ) ) {
414 throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
415 }
416
417 // Check if commenting is disabled on the given project.
418 if ( upstream_are_comments_disabled( $project_id ) ) {
419 throw new \Exception( __( 'Commenting is disabled on this project.', 'upstream' ) );
420 }
421
422 $user_id = get_current_user_id();
423
424 $comment = new Comment( stripslashes( wp_kses_post( $post_data['content'] ) ), $project_id, $user_id );
425 $comment->parent_id = absint( $post_data['parent_id'] );
426 $comment->created_by->ip = preg_replace( '/[^0-9a-fA-F:., ]/', '', sanitize_text_field( $server['REMOTE_ADDR'] ) );
427 $comment->created_by->agent = isset( $server['HTTP_USER_AGENT'] ) ? sanitize_textarea_field( $server['HTTP_USER_AGENT'] ) : null;
428
429 $comment->save();
430
431 update_comment_meta( $comment->id, 'type', $comment_target_item_type );
432
433 if ( 'project' !== $comment_target_item_type ) {
434 update_comment_meta( $comment->id, 'id', sanitize_text_field( $post_data['item_id'] ) );
435 }
436
437 $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
438
439 $parent = get_comment( $comment->parent_id );
440
441 $comments_cache = array(
442 $parent->comment_ID => json_decode(
443 json_encode(
444 array(
445 'created_by' => array(
446 'name' => $parent->comment_author,
447 ),
448 )
449 )
450 ),
451 );
452
453 $response['comment_html'] = stripslashes( $comment->render( true, $use_admin_layout, $comments_cache ) );
454
455 wp_new_comment_notify_moderator( $comment->id );
456 wp_notify_postauthor( $comment->id );
457
458 $response['success'] = true;
459 } catch ( \Exception $e ) {
460 $response['error'] = $e->getMessage();
461 }
462
463 wp_send_json( $response );
464 }
465
466 /**
467 * AJAX endpoint that trashes a comment.
468 *
469 * @throws \Exception Set the error message.
470 * @since 1.13.0
471 * @static
472 */
473 public static function trash_comment() {
474 header( 'Content-Type: application/json' );
475
476 $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
477 $response = array(
478 'success' => false,
479 'error' => null,
480 );
481
482 try {
483 // Check if the request payload is potentially invalid.
484 if (
485 ! defined( 'DOING_AJAX' )
486 || ! DOING_AJAX
487 || empty( $post_data )
488 || ! isset( $post_data['nonce'] )
489 || ! isset( $post_data['project_id'] )
490 || ! isset( $post_data['comment_id'] )
491 || ! check_ajax_referer( 'upstream:project.trash_comment:' . sanitize_textarea_field( $post_data['comment_id'] ), 'nonce', false )
492 ) {
493 throw new \Exception( __( 'Invalid request.', 'upstream' ) );
494 }
495
496 // Check if the project exists.
497 $project_id = absint( $post_data['project_id'] );
498 if ( $project_id <= 0 ) {
499 throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
500 }
501
502 // Check if the Discussion/Comments section is disabled for the current project.
503 if ( upstream_are_comments_disabled( $project_id ) ) {
504 throw new \Exception( __( 'Comments are disabled for this project.', 'upstream' ) );
505 }
506
507 // Check if the parent comment exists.
508 $comment_id = absint( $post_data['comment_id'] );
509 $comment = get_comment( $comment_id );
510
511 if ( empty( $comment )
512 // Check if the comment belongs to that project.
513 || (
514 isset( $comment->comment_post_ID )
515 && (int) $comment->comment_post_ID !== $project_id
516 )
517 ) {
518 throw new \Exception( _x( 'Comment not found.', 'Removing a comment in projects', 'upstream' ) );
519 }
520
521 $user_id = (int) get_current_user_id();
522
523 if ( ! upstream_admin_permissions( 'delete_project_discussion' )
524 && ! current_user_can( 'moderate_comments' )
525 && (int) $comment->user_id !== $user_id
526 ) {
527 throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
528 }
529
530 $success = wp_trash_comment( $comment );
531 if ( ! $success ) {
532 throw new \Exception( __( "It wasn't possible to delete this comment.", 'upstream' ) );
533 }
534
535 $response['success'] = true;
536 } catch ( \Exception $e ) {
537 $response['error'] = $e->getMessage();
538 }
539
540 wp_send_json( $response );
541 }
542
543 /**
544 * AJAX endpoint that unapproves a comment.
545 *
546 * @since 1.13.0
547 * @static
548 */
549 public static function unapprove_comment() {
550 header( 'Content-Type: application/json' );
551
552 $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
553 $comment_id = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
554
555 check_ajax_referer( 'upstream:project.unapprove_comment:' . $comment_id, 'nonce' );
556
557 $response = array(
558 'success' => false,
559 'error' => null,
560 );
561
562 try {
563 $comment = self::toggle_comment_approval_status( $comment_id, false );
564
565 $comments = array();
566 if ( $comment->parent_id > 0 ) {
567 $parent_comment = get_comment( $comment->parent_id );
568 if ( is_numeric( $parent_comment->comment_approved ) ) {
569 if ( (bool) $parent_comment->comment_approved ) {
570 $comments = array(
571 $comment->parent_id => json_decode(
572 json_encode(
573 array(
574 'created_by' => array(
575 'name' => $parent_comment->comment_author,
576 ),
577 )
578 )
579 ),
580 );
581 } else {
582 $user = wp_get_current_user();
583 $user_has_admin_capabilities = upstream_is_user_either_manager_or_admin( $user );
584 $user_can_moderate_comments = ! $user_has_admin_capabilities ? user_can(
585 $user,
586 'moderate_comments'
587 ) : true;
588
589 if ( $user_can_moderate_comments ) {
590 $comments = array(
591 $comment->parent_id => json_decode(
592 json_encode(
593 array(
594 'created_by' => array(
595 'name' => $parent_comment->comment_author,
596 ),
597 )
598 )
599 ),
600 );
601 }
602 }
603 }
604 unset( $parent_comment );
605 }
606
607 $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
608
609 $response['comment_html'] = $comment->render( true, $use_admin_layout, $comments );
610
611 wp_new_comment_notify_moderator( $comment->id );
612
613 $response['success'] = true;
614 } catch ( \Exception $e ) {
615 $response['error'] = $e->getMessage();
616 }
617
618 wp_send_json( $response );
619 }
620
621 /**
622 * Either approves/unapproves a given comment.
623 * This method is called by the correspondent AJAX endpoints.
624 *
625 * @since 1.13.0
626 * @access private
627 * @static
628 *
629 * @throws \Exception When something went wrong or failed on validations.
630 *
631 * @param int $comment_id Comment ID being edited.
632 * @param bool $is_approved Either the comment will be approved or not.
633 */
634 private static function toggle_comment_approval_status( $comment_id, $is_approved ) {
635 $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
636
637 // Check if the request payload is potentially invalid.
638 if (
639 ! defined( 'DOING_AJAX' )
640 || ! DOING_AJAX
641 || empty( $post_data )
642 || ! isset( $post_data['nonce'] )
643 || ! isset( $post_data['project_id'] )
644 || ! isset( $post_data['comment_id'] )
645 || ! check_ajax_referer(
646 'upstream:project.' . ( $is_approved ? 'approve_comment' : 'unapprove_comment' ) . ':' . sanitize_textarea_field( $post_data['comment_id'] ),
647 'nonce',
648 false
649 )
650 ) {
651 throw new \Exception( __( 'Invalid request.', 'upstream' ) );
652 }
653
654 // Check if the user has enough permissions to do this.
655 if ( ! current_user_can( 'moderate_comments' ) ) {
656 throw new \Exception( __( "You're not allowed to do this.", 'upstream' ) );
657 }
658
659 // Check if the project potentially exists.
660 $project_id = absint( $post_data['project_id'] );
661 if ( $project_id <= 0 ) {
662 // translators: %s: parameter name.
663 throw new \Exception( sprintf( __( 'Invalid "%s" parameter.', 'upstream' ), 'project_id' ) );
664 }
665
666 // Check if the Discussion/Comments section is disabled for the current project.
667 if ( upstream_are_comments_disabled( $project_id ) ) {
668 throw new \Exception( __( 'Comments are disabled for this project.', 'upstream' ) );
669 }
670
671 $cid = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
672 $comment = Comment::load( $cid );
673 if ( ! ( $comment instanceof Comment ) ) {
674 throw new \Exception( __( 'Comment not found.', 'upstream' ) );
675 }
676
677 $success = (bool) $is_approved ? $comment->approve() : $comment->unapprove();
678 if ( ! $success ) {
679 throw new \Exception( __( 'Unable to save the data into database.', 'upstream' ) );
680 }
681
682 return $comment;
683 }
684
685 /**
686 * AJAX endpoint that approves a comment.
687 *
688 * @since 1.13.0
689 * @static
690 */
691 public static function approve_comment() {
692 header( 'Content-Type: application/json' );
693
694 $post_data = isset( $_POST ) ? wp_unslash( $_POST ) : array();
695 $comment_id = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
696
697 check_ajax_referer( 'upstream:project.approve_comment:' . $comment_id, 'nonce' );
698
699 $response = array(
700 'success' => false,
701 'error' => null,
702 );
703
704 try {
705 $comment_id = isset( $post_data['comment_id'] ) ? absint( $post_data['comment_id'] ) : 0;
706 $comment = self::toggle_comment_approval_status( $comment_id, true );
707
708 $comments = array();
709 if ( $comment->parent_id > 0 ) {
710 $parent_comment = get_comment( $comment->parent_id );
711 if ( is_numeric( $parent_comment->comment_approved ) ) {
712 if ( (bool) $parent_comment->comment_approved ) {
713 $comments = array(
714 $comment->parent_id => json_decode(
715 wp_json_encode(
716 array(
717 'created_by' => array(
718 'name' => $parent_comment->comment_author,
719 ),
720 )
721 )
722 ),
723 );
724 } else {
725 $user = wp_get_current_user();
726 $user_has_admin_capabilities = upstream_is_user_either_manager_or_admin( $user );
727 $user_can_moderate_comments = ! $user_has_admin_capabilities ? user_can(
728 $user,
729 'moderate_comments'
730 ) : true;
731
732 if ( $user_can_moderate_comments ) {
733 $comments = array(
734 $comment->parent_id => json_decode(
735 json_encode(
736 array(
737 'created_by' => array(
738 'name' => $parent_comment->comment_author,
739 ),
740 )
741 )
742 ),
743 );
744 }
745 }
746 }
747 unset( $parent_comment );
748 }
749
750 $use_admin_layout = ! isset( $post_data['teeny'] ) ? true : boolval( $post_data['teeny'] ) === false;
751
752 $response['comment_html'] = $comment->render( true, $use_admin_layout, $comments );
753
754 $response['success'] = true;
755 } catch ( \Exception $e ) {
756 $response['error'] = $e->getMessage();
757 }
758
759 wp_send_json( $response );
760 }
761
762 /**
763 * AJAX endpoint that fetches all comments from a given item/project.
764 *
765 * @throws \Exception When something went wrong or failed on validations.
766 * @since 1.13.0
767 * @static
768 */
769 public static function fetch_comments() {
770 header( 'Content-Type: application/json' );
771
772 $get_data = isset( $_GET ) ? wp_unslash( $_GET ) : array();
773 $response = array(
774 'success' => false,
775 'data' => array(),
776 'error' => null,
777 );
778
779 try {
780 // Check if the request payload is potentially invalid.
781 if (
782 ! defined( 'DOING_AJAX' )
783 || ! DOING_AJAX
784 || empty( $get_data )
785 || ! isset( $get_data['nonce'] )
786 || ! isset( $get_data['project_id'] )
787 || ! isset( $get_data['item_type'] )
788 || ! self::is_item_type_valid( sanitize_textarea_field( $get_data['item_type'] ) )
789 ) {
790 throw new \Exception( __( 'Invalid request.', 'upstream' ) );
791 }
792
793 // Check if the project potentially exists.
794 $project_id = absint( $get_data['project_id'] );
795 if ( $project_id <= 0 ) {
796 throw new \Exception( __( 'Invalid Project.', 'upstream' ) );
797 }
798
799 // Prepare data to verify nonce.
800 $comment_target_item_type = strtolower( sanitize_text_field( $get_data['item_type'] ) );
801 $item_id = null;
802 if ( 'project' !== $comment_target_item_type ) {
803 if (
804 ! isset( $get_data['item_id'] )
805 || empty( $get_data['item_id'] )
806 ) {
807 throw new \Exception( __( 'Invalid request.', 'upstream' ) );
808 }
809
810 // non-numeric id.
811 $item_id = sanitize_textarea_field( $get_data['item_id'] );
812
813 $nonce_identifier = 'upstream:project.' . $comment_target_item_type . 's.fetch_comments';
814 } else {
815 $nonce_identifier = 'upstream:project.fetch_comments';
816 }
817
818 // Verify nonce.
819 if ( ! check_ajax_referer( $nonce_identifier, 'nonce', false ) ) {
820 throw new \Exception( __( 'Invalid nonce.', 'upstream' ) );
821 }
822
823 // Check if commenting is disabled on the given project.
824 if ( upstream_are_comments_disabled( $project_id ) ) {
825 throw new \Exception( __( 'Commenting is disabled on this project.', 'upstream' ) );
826 }
827
828 $use_admin_layout = ! isset( $get_data['teeny'] ) ? true : boolval( $get_data['teeny'] ) === false;
829
830 $comments_cache = static::get_comments( $project_id, $comment_target_item_type, $item_id );
831
832 foreach ( $comments_cache as $comment ) {
833 if ( 0 === $comment->parent_id ) {
834 ob_start();
835 if ( $use_admin_layout ) {
836 upstream_admin_display_message_item( $comment, array() );
837 } else {
838 upstream_display_message_item( $comment, array() );
839 }
840
841 $response['data'][] = trim( ob_get_contents() );
842 ob_end_clean();
843 }
844 }
845
846 $response['success'] = true;
847 } catch ( \Exception $e ) {
848 $response['error'] = $e->getMessage();
849 }
850
851 wp_send_json( $response );
852 }
853
854 /**
855 * Get comments.
856 *
857 * @param int $project_id Project ID.
858 * @param string $item_type Item type.
859 * @param int $item_id Item ID.
860 *
861 * @return array
862 */
863 public static function get_comments( $project_id, $item_type, $item_id = null ) {
864 $comments_cache = array();
865 $users_cache = array();
866 $users_rowset = get_users(
867 array(
868 'fields' => array(
869 'ID',
870 'display_name',
871 ),
872 )
873 );
874 foreach ( $users_rowset as $user_row ) {
875 $user_row->ID *= 1;
876
877 $users_cache[ $user_row->ID ] = (object) array(
878 'id' => $user_row->ID,
879 'name' => $user_row->display_name,
880 'avatar' => get_userAvatarURL( $user_row->ID ),
881 );
882 }
883 unset( $user_row, $users_rowset );
884
885 if ( 'project' === $item_type ) {
886 $item_id = $project_id;
887 }
888
889 $date_format = get_option( 'date_format' );
890 $time_format = get_option( 'time_format' );
891 $the_date_time_format = $date_format . ' ' . $time_format;
892 $current_timestamp = time();
893 $user = wp_get_current_user();
894 $user_has_admin_capabilities = upstream_is_user_either_manager_or_admin( $user );
895 $user_can_reply = ! $user_has_admin_capabilities ? user_can(
896 $user,
897 'publish_project_discussion'
898 ) : true;
899
900 $user_can_reply = upstream_override_access_field( $user_can_reply, $item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_EDIT );
901
902 $user_can_moderate = ! $user_has_admin_capabilities ? user_can( $user, 'moderate_comments' ) : true;
903 $user_can_delete = ! $user_has_admin_capabilities ? $user_can_moderate || user_can(
904 $user,
905 'delete_project_discussion'
906 ) : true;
907
908 $user_can_delete = upstream_override_access_field( $user_can_delete, $item_type, $item_id, UPSTREAM_ITEM_TYPE_PROJECT, $project_id, 'comments', UPSTREAM_PERMISSIONS_ACTION_DELETE );
909
910 $comments_statuses = array( 'approve' );
911 if ( $user_has_admin_capabilities || $user_can_moderate ) {
912 $comments_statuses[] = 'hold';
913 }
914
915 $items_rowset = (array) get_post_meta(
916 $project_id,
917 '_upstream_project_' . $item_type . 's',
918 true
919 );
920
921 if ( count( $items_rowset ) > 0 ) {
922 foreach ( $items_rowset as $row ) {
923 if ( empty( $row ) ) {
924 continue;
925 }
926
927 if ( ! empty( $item_id ) ) {
928 if ( $item_id != $row['id'] ) {
929 continue;
930 }
931 }
932
933 $comments = (array) get_comments(
934 array(
935 'post_id' => $project_id,
936 'status' => $comments_statuses,
937 'meta_query' => array(
938 'relation' => 'AND',
939 array(
940 'key' => 'type',
941 'value' => $item_type,
942 ),
943 array(
944 'key' => 'id',
945 'value' => $row['id'],
946 ),
947 ),
948 )
949 );
950
951 if ( count( $comments ) > 0 ) {
952 foreach ( $comments as $comment ) {
953 $author = $users_cache[ (int) $comment->user_id ];
954
955 $date = \DateTime::createFromFormat( 'Y-m-d H:i:s', $comment->comment_date_gmt );
956
957 $comment_data = json_decode(
958 json_encode(
959 array(
960 'id' => (int) $comment->comment_ID,
961 'parent_id' => (int) $comment->comment_parent,
962 'content' => $comment->comment_content,
963 'state' => $comment->comment_approved,
964 'created_by' => $author,
965 'created_at' => array(
966 'localized' => '',
967 'humanized' => sprintf(
968 // translators: %s: human-readable time difference.
969 _x( '%s ago', '%s = human-readable time difference', 'upstream' ),
970 human_time_diff( $date->getTimestamp(), $current_timestamp )
971 ),
972 ),
973 'current_user_cap' => array(
974 'can_reply' => $user_can_reply,
975 'can_moderate' => $user_can_moderate,
976 'can_delete' => $user_can_delete || $author->id === $user->ID,
977 ),
978 'replies' => array(),
979 )
980 )
981 );
982
983 $comment_data->created_at->localized = $date->format( $the_date_time_format );
984
985 $comments_cache[ $comment_data->id ] = $comment_data;
986 }
987
988 foreach ( $comments_cache as $comment ) {
989 if ( $comment->parent_id > 0 ) {
990 if ( isset( $comments_cache[ $comment->parent_id ] ) ) {
991 $comments_cache[ $comment->parent_id ]->replies[] = $comment;
992 } else {
993 unset( $comments_cache[ $comment->id ] );
994 }
995 }
996 }
997 }
998 }
999 }
1000
1001 return $comments_cache;
1002 }
1003
1004 /**
1005 * Set additional notification recipients as needed for newly added comments.
1006 *
1007 * @since 1.15.0
1008 * @static
1009 *
1010 * @param array $recipients Recipients list.
1011 * @param int $comment_id The new comment ID.
1012 *
1013 * @return array
1014 */
1015 public static function define_notification_recipients( $recipients, $comment_id ) {
1016 $should_send = upstream_send_notifications_for_new_comments();
1017 if ( ! $should_send ) {
1018 return array();
1019 }
1020
1021 // 2 minutes.
1022 $transient_expiration = 60 * 2;
1023
1024 $comment = get_comment( $comment_id );
1025 $comment = (object) array(
1026 'id' => (int) $comment->comment_ID,
1027 'project_id' => (int) $comment->comment_post_ID,
1028 'parent' => (int) $comment->comment_parent,
1029 'created_by' => (int) $comment->user_id,
1030 'target' => get_comment_meta( $comment_id, 'type', true ),
1031 'target_id' => (int) $comment->comment_post_ID,
1032 );
1033
1034 // Check if we should disable all emaill notifications for this project.
1035 $meta = (array) get_post_meta( $comment->project_id, '_upstream_project_disable_all_notifications' );
1036 if ( count( $meta ) > 0 && 'on' === $meta[0] ) {
1037 return array();
1038 }
1039
1040 // Check if we need to skip further data processing.
1041 if ( ! in_array( $comment->target, array( 'project', 'milestone', 'task', 'bug', 'file' ) ) ) {
1042 return $recipients;
1043 }
1044
1045 $comment->target_label = call_user_func( 'upstream_' . $comment->target . '_label' );
1046
1047 if ( 'project' !== $comment->target ) {
1048 $comment->target_id = get_comment_meta( $comment_id, 'id', true );
1049 }
1050
1051 set_transient( 'upstream:comment_notification.comment:' . $comment_id, $comment, $transient_expiration );
1052
1053 $get_user = function ( $user_id ) use ( $transient_expiration ) {
1054 if ( $user_id <= 0 ) {
1055 return null;
1056 }
1057
1058 // Check if the user is cached.
1059 $user = get_transient( 'upstream:comment_notification.user:' . $user_id );
1060 if ( empty( $user ) ) {
1061 // Check if the user exists.
1062 $user = get_user_by( 'id', $user_id );
1063 if ( false === $user ) {
1064 return null;
1065 }
1066
1067 // Prepare user data.
1068 $user = (object) array(
1069 'id' => (int) $user->ID,
1070 'name' => (string) $user->display_name,
1071 'email' => (string) $user->user_email,
1072 );
1073
1074 // Cache user.
1075 set_transient( 'upstream:comment_notification.user:' . $user->id, $user, $transient_expiration );
1076 }
1077
1078 return $user;
1079 };
1080
1081 $fetch_project_meta_as_map = function ( $project_id, $key, &$map ) use ( $transient_expiration, $get_user ) {
1082
1083 $rowset = array();
1084 if ( 'milestone' === $key ) {
1085 $rowset = (array) ( \UpStream\Milestones::getInstance()->get_milestones_as_rowset( $project_id ) );
1086 } else {
1087 $rowset = (array) get_post_meta( $project_id, '_upstream_project_' . $key . 's', true );
1088 }
1089
1090 foreach ( $rowset as $row ) {
1091 $title_key = 'milestone' !== $key ? 'title' : 'milestone';
1092
1093 if ( isset( $row['id'] )
1094 && ! empty( $row['id'] )
1095 && isset( $row[ $title_key ] )
1096 && ! empty( $row[ $title_key ] )
1097 ) {
1098 $item = (object) array(
1099 'id' => $row['id'],
1100 'title' => $row[ $title_key ],
1101 'assigned_to' => isset( $row['assigned_to'] ) ? $row['assigned_to'] : array(),
1102 'created_by' => isset( $row['created_by'] ) ? (int) $row['created_by'] : 0,
1103 'type' => $key,
1104 );
1105
1106 if ( count( $item->assigned_to ) > 0 ) {
1107 foreach ( $item->assigned_to as $a ) {
1108 $user = $get_user( $a );
1109 $recipients[] = $user->email;
1110 }
1111 }
1112
1113 if ( $item->created_by > 0 ) {
1114 $user = $get_user( $item->created_by );
1115 if ( empty( $user ) ) {
1116 $item->created_by = 0;
1117 } else {
1118 $item->created_by = $user->id;
1119 }
1120 }
1121
1122 $map[ $item->id ] = $item;
1123 }
1124 }
1125
1126 };
1127
1128 // RSD: this cache is causing issues
1129 // $project = get_transient('upstream:comment_notification.project:' . $comment->project_id).
1130 if ( empty( $project ) ) {
1131 $project = get_post( $comment->project_id );
1132 $project = (object) array(
1133 'id' => (int) $project->ID,
1134 'title' => $project->post_title,
1135 'created_by' => (int) $project->post_author,
1136 'owner_id' => (int) get_post_meta( $project->ID, '_upstream_project_owner', true ),
1137 'owner_email' => '',
1138 'milestones' => array(),
1139 'tasks' => array(),
1140 'bugs' => array(),
1141 'files' => array(),
1142 );
1143
1144 if ( $project->owner_id > 0 ) {
1145 $owner = get_transient( 'upstream:comment_notification.user:' . $project->owner_id );
1146 if ( empty( $owner ) ) {
1147 $owner = get_user_by( 'id', $project->owner_id );
1148 $owner = (object) array(
1149 'id' => $project->owner_id,
1150 'name' => (string) $owner->display_name,
1151 'email' => (string) $owner->user_email,
1152 );
1153
1154 set_transient( 'upstream:comment_notification.user:' . $owner->id, $owner, $transient_expiration );
1155 }
1156
1157 $pms = upstream_project_members_ids( $comment->project_id );
1158 foreach ( $pms as $pm ) {
1159 $user_info = get_userdata( $pm );
1160 $email = $user_info->user_email;
1161 $recipients[] = $email;
1162 }
1163 }
1164
1165 if ( 'project' !== $comment->target ) {
1166
1167 $fetch_project_meta_as_map( $project->id, $comment->target, $project->{$comment->target . 's'} );
1168
1169 foreach ( $project->{$comment->target . 's'} as $item ) {
1170 $r = $comment->target_id;
1171 if ( $item->id === $comment->target_id ) {
1172 if ( count( $item->assigned_to ) > 0 ) {
1173 foreach ( $item->assigned_to as $a ) {
1174 $user = $get_user( $a );
1175 $recipients[] = $user->email;
1176 }
1177 }
1178
1179 if ( $item->created_by > 0 ) {
1180 $user = $get_user( $item->created_by );
1181 $recipients[] = $user->email;
1182 }
1183 }
1184 }
1185 }
1186
1187 set_transient(
1188 'upstream:comment_notification.project:' . $comment->project_id,
1189 $project,
1190 $transient_expiration
1191 );
1192 } else {
1193 if ( 'project' !== $comment->target
1194 && empty( $project->{$comment->target . 's'} )
1195 ) {
1196 $fetch_project_meta_as_map( $project->id, $comment->target, $project->{$comment->target . 's'} );
1197
1198 set_transient(
1199 'upstream:comment_notification.project:' . $comment->project_id,
1200 $project,
1201 $transient_expiration
1202 );
1203 }
1204 }
1205
1206 if ( $comment->parent > 0 ) {
1207 $parent_id = $comment->parent;
1208
1209 $users_cache = array();
1210
1211 do {
1212 $parent_comment = get_comment( $parent_id );
1213
1214 $parent_exists = ! empty( $parent_comment );
1215 if ( $parent_exists ) {
1216 if ( ! isset( $users_cache[ $parent_comment->user_id ] ) ) {
1217 $users_cache[ $parent_comment->user_id ] = $get_user( $parent_comment->user_id );
1218 $users_cache[ $parent_comment->user_id ]->notify = upstream_user_can_receive_comment_replies_notification( $parent_comment->user_id );
1219 }
1220
1221 $user = &$users_cache[ $parent_comment->user_id ];
1222
1223 $parent_comment_author = $get_user( $parent_comment->user_id );
1224
1225 if ( $user->notify ) {
1226 $recipients[] = $parent_comment_author->email;
1227 }
1228
1229 $parent_id = (int) $parent_comment->comment_parent;
1230 }
1231 } while ( $parent_exists );
1232 }
1233
1234 $recipients = array_unique( array_filter( $recipients ) );
1235
1236 $recipients = apply_filters( 'upstream:comment_notification.recipients', $recipients, $comment );
1237
1238 return $recipients;
1239 }
1240
1241 /**
1242 * Add additional info to comment notifications subject.
1243 *
1244 * @since 1.15.0
1245 * @static
1246 *
1247 * @param string $subject The original subject.
1248 * @param int $comment_id The new comment ID.
1249 *
1250 * @return string
1251 */
1252 public static function define_notification_header( $subject, $comment_id ) {
1253 $comment = get_transient( 'upstream:comment_notification.comment:' . $comment_id );
1254 // Check if we need to skip further data processing in case of comments written outside UpStream's scope.
1255 if ( empty( $comment )
1256 || in_array( $comment->target, array( 'project', 'milestone', 'task', 'bug', 'file' ) )
1257 ) {
1258 return $subject;
1259 }
1260
1261 $project = get_transient( 'upstream:comment_notification.project:' . $comment->project_id );
1262 $site_name = get_bloginfo( 'name' );
1263
1264 $subject = sprintf(
1265 '[%s][%s] %s',
1266 $site_name,
1267 $project->title,
1268 sprintf(
1269 // translators: %s: Comment notification subject.
1270 _x( 'New comment on %s', 'Comment notification subject', 'upstream' ),
1271 $comment->target_label
1272 )
1273 );
1274
1275 if ( 'project' !== $comment->target ) {
1276 $meta = (array) get_post_meta( $project->id, '_upstream_project_' . $comment->target . 's', true );
1277 foreach ( $meta as $item ) {
1278 if ( isset( $item['id'] ) && $item['id'] === $comment->target_id ) {
1279 $title_key = 'milestone' === $comment->target ? 'milestone' : 'title';
1280
1281 if ( isset( $item[ $title_key ] ) ) {
1282 $subject .= sprintf( ': "%s"', $item[ $title_key ] );
1283 }
1284
1285 break;
1286 }
1287 }
1288 }
1289
1290 $subject = apply_filters( 'upstream:comment_notification.subject', $subject, $comment, $project );
1291
1292 return $subject;
1293 }
1294
1295 /**
1296 * Add items title to notification.
1297 *
1298 * @param string $comment_text Comment text.
1299 * @param int $comment_id Comment ID.
1300 *
1301 * @return mixed
1302 */
1303 public static function add_item_title_to_notification( $comment_text, $comment_id ) {
1304 // Check if the comment has item_title in the metadata.
1305 $item_title = get_comment_meta( $comment_id, 'title', true );
1306 $item_type = get_comment_meta( $comment_id, 'type', true );
1307
1308 if ( ! empty( $item_title ) ) {
1309 if ( 'milestone' === $item_type ) {
1310 // Get the milestone's title.
1311 $milestones = upstream_get_milestones_titles();
1312
1313 if ( isset( $milestones[ $item_title ] ) ) {
1314 $item_title = $milestones[ $item_title ];
1315 }
1316 }
1317
1318 $comment_text = __( 'Item Title: ', 'upstream' ) . $item_title . "\r\n\r\n" . $comment_text;
1319 }
1320
1321 if ( ! empty( $item_type ) ) {
1322 $labels = upstream_get_default_labels();
1323
1324 $item_type_label = $labels[ $item_type . 's' ]['singular'];
1325
1326 $comment_text = __( 'Item Type: ', 'upstream' ) . $item_type_label . "\r\n" . $comment_text;
1327 }
1328
1329 return $comment_text;
1330 }
1331
1332 /**
1333 * Convert notifications text for comments in projects into HTML.
1334 *
1335 * @param string $text Comment text.
1336 * @param int $comment_id Comment ID.
1337 *
1338 * @return string
1339 */
1340 public static function filter_comment_notification_text( $text, $comment_id ) {
1341 if ( self::is_comment_from_project( $comment_id ) ) {
1342 // Convert from txt to html.
1343 $text = str_replace( "\n", '<br>', $text );
1344 $text = self::replace_email_with_html_link( $text );
1345 $text = self::replace_link_with_html_link( $text );
1346 }
1347
1348 return $text;
1349 }
1350
1351 /**
1352 * Is comment from project.
1353 *
1354 * @param int $comment_id Comment ID.
1355 *
1356 * @return bool
1357 */
1358 protected static function is_comment_from_project( $comment_id ) {
1359 // Check if the post is a project.
1360 $comment = get_comment( $comment_id );
1361 $post = get_post( $comment->comment_post_ID );
1362
1363 return 'project' === $post->post_type;
1364 }
1365
1366 /**
1367 * Replace email with HTML link.
1368 *
1369 * @param string $text Email text.
1370 *
1371 * @return string
1372 */
1373 protected static function replace_email_with_html_link( $text ) {
1374 $text = preg_replace( '/([^\s@]+@[a-z\._\-0-9]+)/i', '<a href="mailto:${1}" target="_blank">${1}</a>', $text );
1375
1376 return $text;
1377 }
1378
1379 /**
1380 * Replace link with HTML link.
1381 *
1382 * @param string $text Link.
1383 *
1384 * @return string
1385 */
1386 protected static function replace_link_with_html_link( $text ) {
1387 $text = preg_replace( '~([a-z]+:\/\/\S+)~i', '<a href="${1}" target="_blank">${1}</a>', $text );
1388
1389 return $text;
1390 }
1391
1392 /**
1393 * Convert notifications for comments in projects into HTML.
1394 *
1395 * @param string $headers Headers.
1396 * @param int $comment_id Comment ID.
1397 *
1398 * @return string
1399 */
1400 public static function filter_comment_notification_headers( $headers, $comment_id ) {
1401 if ( self::is_comment_from_project( $comment_id ) ) {
1402 // Convert from txt to html.
1403 $headers = str_replace( 'text/plain;', 'text/html;', $headers );
1404 }
1405
1406 return $headers;
1407 }
1408 }
1409