findTaskOnBoard($comment->task_id, $boardId);
if ($comment->board_id && (int) $comment->board_id !== absint($boardId)) {
throw new \Exception(esc_html__('Comment not found', 'fluent-boards'));
}
return $comment;
}
/**
* Get paginated parent comments with users, images, and replies preloaded.
*/
public function getComments($id, $per_page, $filter, $boardId = null)
{
$task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
$commentsQuery = $task->comments()->whereNull('parent_id')
->with(['user', 'images', 'replies.user', 'replies.images']);
if ($filter == 'oldest') {
$commentsQuery = $commentsQuery->oldest();
} else { // latest or newest
$commentsQuery = $commentsQuery->latest();
}
$comments = $commentsQuery->paginate($per_page);
foreach ($comments as $comment) {
$comment->replies_count = count($comment->replies);
}
return $comments;
}
public function getTotal($id, $boardId = null)
{
$task = $boardId ? (new TaskService())->findTaskOnBoard($id, $boardId) : Task::findOrFail($id);
$totalComment = Comment::where('task_id', $task->id)
->type('comment')
->count();
$totalReply = Comment::where('task_id', $task->id)
->type('reply')
->count();
return $totalComment + $totalReply;
}
public function getReplies($comment)
{
$replies = Comment::where('parent_id', $comment->id)->with(['user'])->get();
return $replies;
}
public function create($commentData, $id, $boardId = null)
{
if ($boardId) {
(new TaskService())->findTaskOnBoard($id, $boardId);
if (!empty($commentData['parent_id'])) {
$parentComment = $this->findCommentOnBoard($commentData['parent_id'], $boardId);
if ((int) $parentComment->task_id !== (int) $id) {
throw new \Exception(esc_html__('Comment not found', 'fluent-boards'));
}
}
}
$comment = Comment::create($commentData);
do_action('fluent_boards/comment_created', $comment);
return $comment;
}
/** Allow only paragraph content and the comment editor's five inline formats. */
public function sanitizeContent($content)
{
if (!is_string($content) || trim(wp_strip_all_tags($content)) === '') {
return '';
}
return trim(wp_kses($content, [
'p' => [], 'br' => [], 'strong' => [], 'b' => [],
'em' => [], 'i' => [], 'del' => [], 's' => [], 'code' => [],
'a' => ['href' => true, 'title' => true],
]));
}
/** Resolve mentions and bare URLs in text without rewriting link attributes or code. */
public function renderContent($content, $mentionData = [])
{
$parts = wp_html_split($this->sanitizeContent($content));
$skipDepth = 0;
foreach ($parts as &$part) {
if (preg_match('~^?(a|code)\b~i', $part)) {
$skipDepth += strpos($part, '') === 0 ? -1 : 1;
$skipDepth = max(0, $skipDepth);
} elseif ($skipDepth === 0 && $part !== '' && $part[0] !== '<') {
$part = $this->processMentionAndLink($part, $mentionData);
}
}
unset($part);
return wp_kses_post(implode('', $parts));
}
private function startsWithAt($word) {
return mb_strpos($word, '@') === 0;
}
private function isValidUrl($url)
{
try {
if (empty($url)) {
return false;
}
$url = trim($url);
// Early return for obviously invalid formats
if ($url === 'http://' || $url === 'https://') {
return false;
}
// Handle www. URLs
if (strpos($url, 'www.') === 0) {
$url = 'http://' . $url;
}
// If it's not already a URL, make it one
elseif (!preg_match('~^(?:f|ht)tps?://~i', $url)) {
$url = 'https://' . $url;
}
$components = wp_parse_url($url);
if (empty($components) || !isset($components['host'])) {
return false;
}
// Additional validation with filter_var
$isValid = filter_var($url, FILTER_VALIDATE_URL) !== false;
return $isValid;
} catch (\Exception $e) {
return false;
}
}
private function extractUrls($text) {
try {
// More permissive URL pattern that handles international domains and various formats
$urlPattern = '%\b(?:(?:https?|ftp):\/\/|www\.)[^\s<>\[\]{}"\']+'
. '(?:\([^\s<>\[\]{}"\')]*\)|[^\s<>\[\]{}"\'\)])*%iu';
if (preg_match_all($urlPattern, $text, $matches)) {
$urls = array_filter($matches[0], function($url) {
$trimmed = trim($url);
return !empty($trimmed);
});
return array_values($urls); // Re-index array
}
return [];
} catch (\Exception $e) {
return [];
}
}
private function extractMentions($text) {
try {
// Pattern that combines zero-width delimiters with international username support
$pattern = '/@\x{200B}([\p{L}\p{N}_. -@]+)\x{200C}/u';
if (preg_match_all($pattern, $text, $matches)) {
$mentions = array_filter($matches[1], function($mention) {
$trimmed = trim($mention);
return !empty($trimmed);
});
return array_values($mentions); // Re-index array
}
return [];
} catch (\Exception $e) {
return [];
}
}
public function processMentionAndLink($commentDescription, $mentionData = [])
{
if ($commentDescription === '' || $commentDescription === null) {
return '';
}
try {
// Ensure UTF-8 encoding with error handling
$commentDescription = mb_convert_encoding($commentDescription, 'UTF-8', 'auto');
// Extract all URLs from the text
$urls = $this->extractUrls($commentDescription);
$urlReplacements = [];
if (!empty($urls)) {
foreach ($urls as $url) {
if ($this->isValidUrl($url)) {
$urlReplacements[$url] = sprintf(
'%1$s',
esc_url($url)
);
}
}
}
// Process mentions
$mentionedUsernames = [];
if (!empty($mentionData) && is_array($mentionData)) {
foreach ($mentionData as $mentionedId) {
$user = get_userdata($mentionedId);
if ($user) {
$mentionedUsernames[$user->user_login] = [
'user_id' => $user->ID,
'display_name' => htmlspecialchars($user->display_name, ENT_QUOTES, 'UTF-8')
];
}
}
}
// Extract all mentions from the text
$mentions = $this->extractMentions($commentDescription);
$mentionReplacements = [];
if (!empty($mentions)) {
foreach ($mentions as $mention) {
if (array_key_exists($mention, $mentionedUsernames)) {
$mentionReplacements['@' . "\u{200B}" . $mention . "\u{200C}"] = sprintf(
'%s',
esc_url(fluent_boards_page_url()),
$mentionedUsernames[$mention]['user_id'],
$mentionedUsernames[$mention]['display_name']
);
}
}
}
// Apply replacements
$originalText = $commentDescription;
// First replace URLs (longer strings first to avoid partial replacements)
if (!empty($urls)) {
usort($urls, function($a, $b) {
return strlen($b) - strlen($a);
});
foreach ($urls as $url) {
if (isset($urlReplacements[$url])) {
$commentDescription = str_replace($url, $urlReplacements[$url], $commentDescription);
}
}
}
// Then replace mentions
if (!empty($mentionReplacements)) {
foreach ($mentionReplacements as $mention => $replacement) {
$commentDescription = str_replace($mention, $replacement, $commentDescription);
}
}
return $commentDescription;
} catch (\Exception $e) {
return $commentDescription; // Return original text if processing fails
}
}
public function checkIfCommentHaveLinks($comment)
{
if (empty($comment)) {
return '';
}
try {
// Ensure UTF-8 encoding
$comment = mb_convert_encoding($comment, 'UTF-8', 'auto');
// Extract all URLs from the text
$urls = $this->extractUrls($comment);
$hasLinks = false;
// Replace URLs with links
if (!empty($urls)) {
foreach ($urls as $url) {
if ($this->isValidUrl($url)) {
$hasLinks = true;
$replacement = sprintf(
'%1$s',
esc_url($url)
);
$comment = str_replace($url, $replacement, $comment);
}
}
}
return $hasLinks ? $comment : $comment;
} catch (\Exception $e) {
return $comment; // Return original text if processing fails
}
}
/**
* Retain this comment's images or claim the current user's pending uploads on its board.
* Validate the complete list before changing attachments or removing omitted images.
*/
public function attachCommentImages($comment, $imageIds)
{
$imageIds = $this->normalizeCommentImageIds($imageIds);
$commentId = absint($comment->id);
$boardId = absint($comment->board_id);
$taskId = absint($comment->task_id);
$userId = get_current_user_id();
if (!$commentId || !$boardId || !$userId) {
throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards'));
}
App::getInstance('db')->transaction(function () use ($imageIds, $commentId, $boardId, $taskId, $userId) {
// Serialize edits to this comment and prevent concurrent claims of the same upload.
Comment::withoutGlobalScopes()->where('id', $commentId)->where('board_id', $boardId)->lockForUpdate()->firstOrFail();
$images = CommentImage::whereIn('id', $imageIds)
->where('object_type', Constant::COMMENT_IMAGE)
->orderBy('id')
->lockForUpdate()
->get();
if ($images->count() !== count($imageIds)) {
throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards'));
}
foreach ($images as $image) {
if ((int) $image->object_id === $commentId) {
continue;
}
if ((int) $image->object_id !== 0
|| !$this->commentImageScopeMatches($image, $boardId, $taskId, $userId)) {
throw new RuntimeException(__('Invalid comment image selection.', 'fluent-boards'));
}
}
foreach ($images as $image) {
if ((int) $image->object_id === 0) {
$image->object_id = $commentId;
if (!$image->save()) {
throw new RuntimeException(__('Could not attach comment image.', 'fluent-boards'));
}
}
}
$removedImages = CommentImage::where('object_id', $commentId)
->where('object_type', Constant::COMMENT_IMAGE)
->whereNotIn('id', $imageIds)
->get();
foreach ($removedImages as $image) {
if (!$image->delete()) {
throw new RuntimeException(__('Could not remove comment image.', 'fluent-boards'));
}
}
});
}
/**
* Allow retained images on this comment and validate all newly supplied uploads.
*/
public function assertCommentImagesAttachableForComment($comment, $imageIds)
{
$imageIds = $this->normalizeCommentImageIds($imageIds);
if (empty($imageIds)) {
return [];
}
$commentImages = CommentImage::where('object_id', $comment->id)
->where('object_type', Constant::COMMENT_IMAGE)
->get();
foreach ($commentImages as $commentImage) {
$key = array_search((int) $commentImage->id, $imageIds, true);
if ($key !== false) {
unset($imageIds[$key]);
}
}
return $this->assertCommentImagesAttachable(
array_values($imageIds),
$comment->board_id,
$comment->task_id
);
}
/**
* Reject the entire image list unless every upload is unbound and owned by this actor, board, and task.
*/
public function assertCommentImagesAttachable($imageIds, $boardId, $taskId)
{
$imageIds = $this->normalizeCommentImageIds($imageIds);
if (empty($imageIds)) {
return [];
}
$currentUserId = get_current_user_id();
if (!$currentUserId) {
throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
}
$attachments = CommentImage::whereIn('id', $imageIds)
->where('object_id', 0)
->where('object_type', Constant::COMMENT_IMAGE)
->get();
if (count($attachments) !== count($imageIds)) {
throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
}
foreach ($attachments as $attachment) {
if (!$this->commentImageScopeMatches($attachment, $boardId, $taskId, $currentUserId)) {
throw new \Exception(esc_html__('Invalid comment image attachment', 'fluent-boards'));
}
}
return $attachments;
}
/**
* Fail closed for legacy uploads without recorded board, task, and uploader ownership.
*/
private function commentImageScopeMatches($attachment, $boardId, $taskId, $userId)
{
$settings = is_array($attachment->settings) ? $attachment->settings : [];
$scope = isset($settings['comment_image_scope']) && is_array($settings['comment_image_scope'])
? $settings['comment_image_scope']
: [];
return intval($scope['board_id'] ?? 0) === intval($boardId)
&& intval($scope['task_id'] ?? 0) === intval($taskId)
&& intval($scope['created_by'] ?? 0) === intval($userId);
}
/**
* Record trusted upload ownership in attachment metadata before saving.
*/
public function applyCommentImageScope($attachment, $boardId, $taskId, $createdBy = null)
{
$settings = is_array($attachment->settings) ? $attachment->settings : [];
$settings['comment_image_scope'] = [
'board_id' => intval($boardId),
'task_id' => intval($taskId),
'created_by' => intval($createdBy === null ? get_current_user_id() : $createdBy),
];
$attachment->settings = $settings;
return $attachment;
}
/**
* Normalize submitted image IDs and remove duplicates before validating the full list.
*/
private function normalizeCommentImageIds($imageIds)
{
if (!is_array($imageIds)) {
return [];
}
return array_values(array_filter(array_unique(array_map('intval', $imageIds))));
}
public function update($commentData, $comment_id, $mentionData, $boardId = null)
{
$comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
if ($comment->created_by != get_current_user_id()) {
return false;
}
$effectiveBoardId = absint($comment->board_id ?: $boardId);
$notificationService = new NotificationService();
$existingMentionedIds = array_values(array_unique(array_filter(array_map('absint', (array) ($comment->settings['mentioned_id'] ?? [])))));
$newMentionedIds = array_values(array_unique(array_filter(array_map('absint', (array) $mentionData))));
$requestedMentionedIds = array_values(array_unique(array_merge($existingMentionedIds, $newMentionedIds)));
$allMentionedIds = $notificationService->resolveBoardMentionUserIds($effectiveBoardId, $requestedMentionedIds);
if (array_diff($newMentionedIds, $allMentionedIds)) {
throw new \Exception(esc_html__('One or more mentioned users are not members of this board', 'fluent-boards'), 403);
}
$commentData['description'] = $this->sanitizeContent($commentData['description']);
$processedDescription = $this->renderContent($commentData['description'], $allMentionedIds);
$oldComment = $comment->settings['raw_description'] ?? $comment->description;
$comment->description = $processedDescription;
if($comment->settings != null)
{
$tempSettings = $comment->settings;
$tempSettings['raw_description'] = $commentData['description'];
$tempSettings['mentioned_id'] = $allMentionedIds;
$comment->settings = $tempSettings;
} else {
$comment->settings = [
'raw_description' => $commentData['description'],
'mentioned_id' => $allMentionedIds
];
}
$comment->save();
if(!$comment->parent_id) {
do_action('fluent_boards/comment_updated', $comment, $oldComment);
}
return $comment;
}
public function delete($comment_id, $boardId = null)
{
$comment = $boardId ? $this->findCommentOnBoard($comment_id, $boardId) : Comment::findOrFail($comment_id);
if ($comment->created_by != get_current_user_id()) {
return false;
}
// Delete related replies first (model event will handle their images)
$this->relatedReplyDelete($comment_id);
// Delete the comment (model deleting event will handle images and comments_count)
$comment->delete();
do_action('fluent_boards/comment_deleted', $comment);
}
public function relatedReplyDelete($comment_id)
{
$replies = Comment::where('parent_id', $comment_id)
->type('reply')
->get();
foreach ($replies as $reply) {
// Delete reply (model deleting event will handle images)
$reply->delete();
}
}
public function updateReply($replyData, $id, $boardId = null)
{
$reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
if ($reply->created_by != get_current_user_id()) {
return false;
}
$oldReply = $reply->description;
$reply->description = $replyData['description'];
$reply->save();
// do_action('fluent_boards/task_comment_updated', $comment->task_id, $oldComment, $comment->description);
return $reply;
}
public function deleteReply($id, $boardId = null)
{
$reply = $boardId ? $this->findCommentOnBoard($id, $boardId) : Comment::findOrFail($id);
// $taskId = $reply->task_id;
if ($reply->created_by != get_current_user_id()) {
return false;
}
// Delete reply (model deleting event will handle images)
$reply->delete();
// do_action('fluent_boards/comment_deleted', $taskId);
}
/**
* Persist an unbound comment upload with trusted board, task, and uploader metadata.
* Legacy uploads without this scope cannot be newly attached to a comment.
*
* @return CommentImage
*/
public function createCommentImage($data, $boardId, $taskId = null)
{
/*
* I will refactor this function later- within March 2024 Last Week
*/
$initialDataData = [
'type' => 'url',
'url' => '',
'name' => '',
'size' => 0,
];
$attachData = array_merge($initialDataData, $data);
$UrlMeta = [];
if($attachData['type'] == 'url') {
$UrlMeta = RemoteUrlParser::parse($attachData['url']);
}
$attachment = new CommentImage();
$attachment->object_id = 0;
$attachment->object_type = Constant::COMMENT_IMAGE;
$attachment->attachment_type = $attachData['type'];
$attachment->title = $this->setTitle($attachData['type'], $attachData['name'], $UrlMeta);
$attachment->file_path = $attachData['type'] != 'url' ? $attachData['file'] : null;
$attachment->full_url = esc_url($attachData['url']);
$attachment->file_size = $attachData['size'];
$settings = $attachData['type'] == 'url' ? [
'meta' => $UrlMeta
] : [];
$settings['board_id'] = absint($boardId);
$attachment->settings = $settings + [
Constant::ATTACHMENT_UPLOAD_BOARD_ID => absint($boardId),
Constant::ATTACHMENT_UPLOAD_USER_ID => get_current_user_id(),
];
$this->applyCommentImageScope($attachment, $boardId, $taskId);
$attachment->driver = 'local';
$attachment->save();
return $attachment;
}
public function createPublicUrl($attachment, $boardId)
{
$boardId = absint($boardId);
return add_query_arg([
'fbs' => 1,
'fbs_type' => 'public_url',
'fbs_comment_image' => $attachment->file_hash,
], site_url('/index.php'));
}
private function setTitle($type, $title, $UrlMeta)
{
if($type != 'url') {
return sanitize_file_name($title);
}
return $title ?? $UrlMeta['title'] ?? '';
}
}