PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.2.1
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.2.1
2.2.1 2.2.0 2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 All 44 releases
← All changes | app/Hooks/Handlers/ExternalPages.php +68 -87 1.35 → 2.2.1 View file →
@@ -1,14 +1,12 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Hooks\Handlers;
4 4
5 -use FluentBoards\App\Models\Attachment;
6 -use FluentBoards\App\App;
7 5 use FluentBoards\App\Models\CommentImage;
8 -use FluentBoards\App\Models\Meta;
9 6 use FluentBoards\App\Models\Task;
10 -use FluentBoards\App\Services\Constant;
7 +use FluentBoards\App\Services\AttachmentAccessService;
8 +use FluentBoards\App\Services\PermissionManager;
11 9 use FluentBoards\App\Services\Libs\FileSystem;
12 10
13 11 class ExternalPages
14 12 {
@@ -13,38 +11,51 @@
13 11 class ExternalPages
14 12 {
15 13 public function view_uploaded_comment_image()
16 14 {
17 - $attachmentHash = sanitize_text_field($_REQUEST['fbs_comment_image']);
18 - $boardId = sanitize_text_field($_REQUEST['fbs_bid']);
15 + nocache_headers();
16 + header('Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0');
19 17
18 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only endpoint; current permissions are checked for every request
19 + $attachmentHash = isset($_REQUEST['fbs_comment_image']) && is_string($_REQUEST['fbs_comment_image'])
20 + ? sanitize_text_field(wp_unslash($_REQUEST['fbs_comment_image'])) : '';
21 +
20 22 if (empty($attachmentHash)) {
21 - die('Invalid Attachment Hash');
23 + wp_die(esc_html__('Invalid Attachment Hash', 'fluent-boards'), '', ['response' => 404]);
22 24 }
23 25
24 26 $attachment = $this->getUploadedImageByHash($attachmentHash);
25 27
26 28 if (!$attachment) {
27 - die('Invalid Attachment Hash');
29 + wp_die(esc_html__('Invalid Attachment Hash', 'fluent-boards'), '', ['response' => 404]);
28 30 }
29 31
32 + $boardId = (new AttachmentAccessService())->getAccessibleBoardId($attachment);
33 + if (!$boardId) {
34 + wp_die(esc_html__('You do not have permission to view this image.', 'fluent-boards'), '', ['response' => 403]);
35 + }
36 +
37 + // Old URLs may include a board ID, but it can never choose the file directory.
38 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Optional compatibility parameter is checked against the attachment's board
39 + if (isset($_REQUEST['fbs_bid']) && (!is_scalar($_REQUEST['fbs_bid']) || absint(wp_unslash($_REQUEST['fbs_bid'])) !== $boardId)) {
40 + wp_die(esc_html__('You do not have permission to view this image.', 'fluent-boards'), '', ['response' => 403]);
41 + }
42 +
30 43 if ('local' !== $attachment->driver) {
31 44 if(!empty($attachment->file_path)){
32 45 $this->redirectToExternalAttachment($attachment->full_url);
33 46 }else{
34 - die('File could not be found');
47 + wp_die(esc_html__('File could not be found', 'fluent-boards'), '', ['response' => 404]);
35 48 }
36 49 return;
37 50 }
38 - $fileName = $attachment->file_path;
39 - $boardId = $boardId;
40 - $filePath = $fileName;
41 - if(!file_exists($fileName)){
42 - $filePath = FileSystem::setSubDir('board_' . $boardId)->getDir() . DIRECTORY_SEPARATOR . $fileName;
43 - }
44 51
45 - if (!file_exists($filePath)) {
46 - die('File could not be found.');
52 + // Preserve legacy absolute paths inside plugin uploads; bare filenames use the trusted board.
53 + $filePath = FileSystem::resolveLocalAttachmentPath($attachment->file_path)
54 + ?: FileSystem::resolveLocalAttachmentPath($attachment->file_path, $boardId);
55 +
56 + if (!$filePath || !is_readable($filePath)) {
57 + wp_die(esc_html__('File could not be found.', 'fluent-boards'), '', ['response' => 404]);
47 58 }
48 59
49 60 $this->serveLocalAttachment($attachment, $filePath);
50 61 }
@@ -50,60 +61,9 @@
50 61 }
51 62
52 63 public function view_comment_image()
53 64 {
54 - $attachmentHash = sanitize_text_field($_REQUEST['fbs_comment_image']);
55 -
56 - if (empty($attachmentHash)) {
57 - die('Invalid Attachment Hash');
58 - }
59 -
60 - $attachment = $this->getUploadedImageByHash($attachmentHash);
61 -
62 - if (in_array($attachment->object_type, [Constant::COMMENT_IMAGE])) {
63 - $attachment->load('comment');
64 - } elseif (in_array($attachment->object_type, [Constant::TASK_DESCRIPTION])) {
65 - $attachment['task'] = Task::find($attachment->object_id);
66 - }
67 -
68 - if (!$attachment) {
69 - die('Invalid Attachment Hash');
70 - }
71 -
72 - // check signature hash
73 - if (!$this->validateAttachmentSignature($attachment)) {
74 - $dieMessage = __('Sorry, Your secure sign is invalid, Please reload the previous page and get new signed url', 'fluent-support');
75 - die($dieMessage);
76 - }
77 -
78 - //If external file
79 - if ('local' !== $attachment->driver) {
80 - if(!empty($attachment->file_path)){
81 - $this->redirectToExternalAttachment($attachment->full_url);
82 - }else{
83 - die('File could not be found');
84 - }
85 - }
86 -
87 - //Handle Local file
88 - if (in_array($attachment->object_type, [Constant::COMMENT_IMAGE])) {
89 - $fileName = $attachment->file_path;
90 - $boardId = $attachment->comment->board_id;
91 - } elseif (in_array($attachment->object_type, [Constant::TASK_DESCRIPTION])) {
92 - $fileName = $attachment->file_path;
93 - $boardId = $attachment->task->board_id;
94 - }
95 -
96 - $filePath = $fileName;
97 - if(!file_exists($fileName)){
98 - $filePath = FileSystem::setSubDir('board_' . $boardId)->getDir() . DIRECTORY_SEPARATOR . $fileName;
99 - }
100 -
101 - if (!file_exists($filePath)) {
102 - die('File could not be found.');
103 - }
104 -
105 - $this->serveLocalAttachment($attachment, $filePath);
65 + $this->view_uploaded_comment_image();
106 66 }
107 67
108 68 private function getUploadedImageByHash($attachmentHash)
109 69 {
@@ -111,25 +71,48 @@
111 71 }
112 72
113 73 private function serveLocalAttachment($attachment, $filePath)
114 74 {
115 - ob_get_clean();
116 - header("Content-Type: {$attachment->attachment_type}");
117 - header("Content-Disposition: inline; filename=\"{$attachment->title}\"");;
118 - echo readfile($filePath);
75 + if (ob_get_level()) {
76 + ob_end_clean();
77 + }
78 + // Stored MIME values and titles may come from legacy or imported records.
79 + $mime = wp_get_image_mime($filePath);
80 + $safeImages = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/bmp', 'image/tiff', 'image/x-icon', 'image/heic'];
81 + $inline = in_array($mime, $safeImages, true);
82 + $filename = sanitize_file_name($attachment->title ?: basename($filePath));
83 + header('Content-Type: ' . ($inline ? $mime : 'application/octet-stream'));
84 + header('X-Content-Type-Options: nosniff');
85 + header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . '; filename="' . ($filename ?: 'attachment') . '"');
86 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Serving binary file content directly to browser, WP_Filesystem not suitable for this use case
87 + readfile($filePath);
119 88 die();
120 89 }
121 90
122 - private function validateAttachmentSignature($attachment)
91 + public function redirectToPage()
123 92 {
124 - $sign = md5($attachment->id . date('YmdH'));
125 - return $sign === $_REQUEST['secure_sign'];
126 - }
93 + nocache_headers();
94 + header('Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0');
127 95
128 - public function redirectToPage()
129 - {
130 - $taskId = $_GET['taskId'];
131 - $task = Task::findOrFail($taskId);
96 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only redirect; current board access is checked below
97 + $taskId = isset($_GET['taskId']) && is_scalar($_GET['taskId']) ? absint(wp_unslash($_GET['taskId'])) : 0;
98 +
99 + if (!$taskId) {
100 + wp_die(esc_html__('Task not found', 'fluent-boards'), '', ['response' => 404]);
101 + }
102 +
103 + // Authenticate before looking up task metadata, retaining the email link after login.
104 + if (!is_user_logged_in()) {
105 + $returnUrl = add_query_arg(['redirect' => 'to_task', 'taskId' => $taskId], site_url('/'));
106 + // Match WordPress login redirects, including trusted SSO login_url filters.
107 + wp_redirect(wp_login_url($returnUrl));
108 + exit;
109 + }
110 +
111 + $task = Task::find($taskId);
112 + if (!$task || !PermissionManager::userHasPermission($task->board_id)) {
113 + wp_die(esc_html__('Task not found', 'fluent-boards'), '', ['response' => 404]);
114 + }
132 115 if ($this->isFrontendEnabled() == 'no') {
133 116 $urlBase = apply_filters('fluent_boards/app_url', admin_url('admin.php?page=fluent-boards#/'));
134 117 $page_url = $urlBase . 'boards/' . $task->board_id . '/tasks/' . $task->id . '-' .substr($task->title, 0, 10);
135 118 wp_redirect($page_url);
@@ -134,9 +117,9 @@
134 117 $page_url = $urlBase . 'boards/' . $task->board_id . '/tasks/' . $task->id . '-' .substr($task->title, 0, 10);
135 118 wp_redirect($page_url);
136 119 exit;
137 120 } else {
138 - $urlBase = apply_filters('fluent_boards/app_url');
121 + $urlBase = apply_filters('fluent_boards/app_url', admin_url('admin.php?page=fluent-boards#/'));
139 122 $page_url = $urlBase . 'boards/' . $task->board_id . '/tasks/' . $task->id . '-' .substr($task->title, 0, 10);
140 123 wp_redirect($page_url);
141 124 exit;
142 125 }
@@ -146,13 +129,11 @@
146 129
147 130 private function isFrontendEnabled()
148 131 {
149 132 $storedSettings = get_option('fluent_boards_modules', []);
150 - if ($storedSettings && is_array($storedSettings)) {
151 - $settings = maybe_serialize($storedSettings);
152 - }
133 + $settings = is_string($storedSettings) ? maybe_unserialize($storedSettings) : $storedSettings;
153 134
154 - if (isset($settings['frontend']['enabled'])) {
135 + if (is_array($settings) && isset($settings['frontend']['enabled'])) {
155 136 return $settings['frontend']['enabled'];
156 137 }
157 138
158 139 return 'no';
@@ -161,5 +142,5 @@
161 142 {
162 143 wp_redirect($redirectUrl, 307);
163 144 exit();
164 145 }
165 -}
146 +}