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

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

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