PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
← All changes | app/Http/Controllers/UploaderController.php +223 -25 1.10.1 → 2.4.5 View file →
@@ -3,11 +3,13 @@
3 3 namespace FluentSupport\App\Http\Controllers;
4 4
5 5 use FluentSupport\App\Models\Attachment;
6 6 use FluentSupport\App\Models\Ticket;
7 +use FluentSupport\App\Modules\PermissionManager;
8 +use FluentSupport\App\Services\CustomerPortalService;
7 9 use FluentSupport\App\Services\EmailNotification\Settings;
8 10 use FluentSupport\App\Services\Helper;
9 -use FluentSupport\Framework\Request\Request;
11 +use FluentSupport\Framework\Http\Request\Request;
10 12 use FluentSupport\App\Services\Includes\UploadService;
11 13
12 14 /**
13 15 * UploaderController class is responsible for uploading file
@@ -17,8 +19,15 @@
17 19 */
18 20 class UploaderController extends Controller
19 21 {
20 22 /**
23 + * Uploads sent with on_behalf belong to that customer, and their ticket_id is the
24 + * public ticket number. Remote portals check for this constant to know which
25 + * ticket reference to send. Don't remove it.
26 + */
27 + const ON_BEHALF_UPLOADS = true;
28 +
29 + /**
21 30 * uploadTicketFiles method will upload all the attached file in a ticket
22 31 * @param Request $request
23 32 * @return array[]
24 33 * @throws \FluentSupport\Framework\Validator\ValidationException
@@ -26,23 +35,53 @@
26 35 public function uploadTicketFiles(Request $request)
27 36 {
28 37 $settings = (new Settings())->globalBusinessSettings();
29 38 $maxFileSize = floatval($settings['max_file_size']);
39 + $maxFileUpload = intval($settings['max_file_upload']);
30 40 $mimeHeadings = Helper::getAcceptedMimeHeadings();
31 41 $maxSizeBytes = $maxFileSize * 1024;
32 42 $imageType = $request->type ? $request->type : null;
33 43
34 - $this->validateUploadedFiles($request->files(), $maxSizeBytes, $mimeHeadings, $maxFileSize);
35 - $ticketId = $this->resolveTicketId($request);
36 - $person = $this->resolvePerson($ticketId, $request);
44 + $files = $request->files();
37 45
38 - $this->checkPermissionToUploadFile($person);
46 + if ($partsError = $this->rejectUnexpectedFileParts($files)) {
47 + return $partsError;
48 + }
39 49
50 + $onBehalf = (new CustomerPortalService())->sanitizeOnBehalf($request->get('on_behalf', []));
51 +
52 + if ($onBehalf) {
53 + try {
54 + list($ticketId, $person) = $this->resolveOnBehalfUploader($onBehalf, $request);
55 + } catch (\Exception $e) {
56 + return $this->sendError([
57 + 'message' => Helper::getSafeErrorMessage($e),
58 + ]);
59 + }
60 + } else {
61 + $ticketId = $this->resolveTicketId($request);
62 + $person = $this->resolvePerson($ticketId, $request);
63 + }
64 +
65 + if ($permissionError = $this->checkPermissionToUploadFile($person)) {
66 + return $permissionError;
67 + }
68 +
69 + if ($accessError = $this->checkTicketAccess($ticketId)) {
70 + return $accessError;
71 + }
72 +
73 + if ($quotaError = $this->checkAttachmentQuota($files, $person, $ticketId, $maxFileUpload)) {
74 + return $quotaError;
75 + }
76 +
77 + $this->validateUploadedFiles($files, $maxSizeBytes, $mimeHeadings, $maxFileSize);
78 +
40 79 try {
41 - $uploadedFiles = UploadService::handleTempFileUpload($request->files());
80 + $uploadedFiles = UploadService::handleTempFileUpload($files);
42 81 } catch (\Exception $e) {
43 82 return $this->sendError([
44 - 'message' => $e->getMessage(),
83 + 'message' => Helper::getSafeErrorMessage($e),
45 84 ]);
46 85 }
47 86
48 87 if (is_wp_error($uploadedFiles)) {
@@ -57,8 +96,73 @@
57 96 'attachments' => $attachmentHashes,
58 97 ];
59 98 }
60 99
100 + /**
101 + * Only the "file" multipart part is validated and processed downstream
102 + * (UploadService/FileSystem::put() loops every top-level part it is given), so
103 + * any other part name must be rejected here rather than silently passed through.
104 + */
105 + private function rejectUnexpectedFileParts($files)
106 + {
107 + $files = (array) $files;
108 + $unexpectedKeys = array_diff(array_keys($files), ['file']);
109 +
110 + if ($unexpectedKeys || empty($files['file'])) {
111 + return $this->sendError([
112 + 'message' => __('Invalid file upload request.', 'fluent-support'),
113 + ]);
114 + }
115 +
116 + return null;
117 + }
118 +
119 + /**
120 + * resolveTicketId() passes an agent's ticket_id through unchecked, so authorize it
121 + * before anything is written. No ticket id is legitimate — the Add Ticket form
122 + * uploads before the ticket exists.
123 + */
124 + private function checkTicketAccess($ticketId)
125 + {
126 + if (!$ticketId || !Helper::getCurrentAgent()) {
127 + return null;
128 + }
129 +
130 + $ticket = Ticket::find($ticketId);
131 +
132 + if (!$ticket || !PermissionManager::canAccessTicket($ticket)) {
133 + return $this->sendError([
134 + 'message' => __('You do not have permission to upload a file to this ticket', 'fluent-support'),
135 + ], 403);
136 + }
137 +
138 + return null;
139 + }
140 +
141 + private function checkAttachmentQuota($files, $person, $ticketId, $maxFileUpload)
142 + {
143 + if ($maxFileUpload <= 0) {
144 + return null;
145 + }
146 +
147 + $newFiles = isset($files['file']) ? $files['file'] : null;
148 + $newFilesCount = is_array($newFiles) ? count($newFiles) : 1;
149 +
150 + $existingCount = Attachment::where('person_id', $person->id)
151 + ->where('ticket_id', $ticketId)
152 + ->where('status', 'in-active')
153 + ->count();
154 +
155 + if (($existingCount + $newFilesCount) > $maxFileUpload) {
156 + return $this->sendError([
157 + // translators: %d is the maximum number of files allowed per ticket
158 + 'message' => sprintf(__('You can upload a maximum of %d files.', 'fluent-support'), $maxFileUpload),
159 + ]);
160 + }
161 +
162 + return null;
163 + }
164 +
61 165 private function validateUploadedFiles($files, $maxSizeBytes, $mimeHeadings, $maxFileSize)
62 166 {
63 167 $validationRules = [
64 168 'file' => 'max:' . $maxSizeBytes . '|mimetypes:' . implode(',', Helper::ticketAcceptedFileMiles()),
@@ -64,9 +168,11 @@
64 168 'file' => 'max:' . $maxSizeBytes . '|mimetypes:' . implode(',', Helper::ticketAcceptedFileMiles()),
65 169 ];
66 170
67 171 $validationMessages = [
172 + // translators: %s is a comma-separated list of allowed file types (e.g., "jpg, png, pdf")
68 173 'file.mimetypes' => sprintf(__('Only %s files are allowed.', 'fluent-support'), implode(', ', $mimeHeadings)),
174 + // translators: %.01f is the maximum file size in megabytes
69 175 'file.max' => sprintf(__('The file cannot be more than %.01fMB. Please upload somewhere like Dropbox/Google Drive and paste the link in the response', 'fluent-support'), $maxFileSize),
70 176 ];
71 177
72 178 $this->validate($files, $validationRules, $validationMessages);
@@ -74,15 +180,61 @@
74 180
75 181 private function resolveTicketId($request)
76 182 {
77 183 $ticketId = $request->getSafe('ticket_id', 'intval');
78 - return $ticketId == 'undefined' ? null : $ticketId;
184 +
185 + if ($ticketId == 'undefined' || !$ticketId) {
186 + return null;
187 + }
188 +
189 + if (Helper::getCurrentAgent()) {
190 + return $ticketId;
191 + }
192 +
193 + $ticket = Ticket::wherePublicIdentifier($ticketId)->first();
194 +
195 + return $ticket ? $ticket->id : null;
79 196 }
80 197
198 + /**
199 + * A remote portal (PortalPolicy has allowed on_behalf) uploads as the customer it
200 + * acts for, so the file belongs to that customer and counts toward their quota only.
201 + * ticket_id is the public ticket number, as on every other portal endpoint.
202 + *
203 + * @return array [ticket id or null, customer]
204 + * @throws \Exception
205 + */
206 + private function resolveOnBehalfUploader($onBehalf, Request $request)
207 + {
208 + $portalService = new CustomerPortalService();
209 + $identifier = $request->getSafe('ticket_id', 'intval');
210 +
211 + if (!$identifier) {
212 + return [null, $portalService->resolveCustomer($onBehalf, $request->getIp(), true)];
213 + }
214 +
215 + $ticket = Ticket::wherePublicIdentifier($identifier)->first();
216 +
217 + if (!$ticket) {
218 + throw new \Exception(esc_html__('Sorry, You do not have permission to this support ticket', 'fluent-support'));
219 + }
220 +
221 + // getCustomer() also holds an agent to their own mailbox and ticket access.
222 + $customer = $portalService->getCustomer([
223 + 'on_behalf' => $onBehalf,
224 + 'user_ip' => $request->getIp(),
225 + ], $ticket);
226 +
227 + $portalService->checkCustomerTicketAccess($customer, $ticket, 'response');
228 +
229 + return [$ticket->id, $customer];
230 + }
231 +
81 232 private function resolvePerson($ticketId, Request $request)
82 233 {
83 - if ($request->get('is_agent') == 'yes') {
84 - return Helper::getCurrentAgent();
234 + $agent = Helper::getCurrentAgent();
235 + if ($agent) {
236 + return $agent;
85 237 }
86 238
87 239 if ($ticketId && Helper::isPublicSignedTicketEnabled()) {
88 240 $intendedTicketHash = $request->getSafe('intended_ticket_hash', 'sanitize_text_field');
@@ -88,9 +240,10 @@
88 240 $intendedTicketHash = $request->getSafe('intended_ticket_hash', 'sanitize_text_field');
89 241 if ($intendedTicketHash && $intendedTicketHash != 'undefined') {
90 242 $ticket = Ticket::with(['customer'])
91 243 ->where('hash', $intendedTicketHash)
92 - ->find($ticketId);
244 + ->wherePublicIdentifier($ticketId)
245 + ->first();
93 246
94 247 if ($ticket && $ticket->customer) {
95 248 return $ticket->customer;
96 249 }
@@ -120,9 +273,9 @@
120 273
121 274 private function createAttachmentRecords($uploadedFiles, $ticketId, $person, $imageType)
122 275 {
123 276 $attachments = [];
124 - $full_path = null;
277 + $directPasteUrl = null;
125 278
126 279 foreach ($uploadedFiles as $file) {
127 280 if (empty($file['file_path'])) continue;
128 281
@@ -139,15 +292,16 @@
139 292 'local_temp_path' => $file['file_path'],
140 293 ]
141 294 ];
142 295
143 - if($imageType == 'direct_paste'){
144 - $full_path = esc_url($file['url']);
145 - }
146 -
147 296 try {
148 297 $attachment = Attachment::create($fileData);
149 298 $attachments[] = $attachment->file_hash;
299 +
300 + if ($imageType == 'direct_paste') {
301 + $directPasteUrl = $attachment->secureUrl;
302 + }
303 +
150 304 do_action('fluent_support/attachment_uploaded_as_temp', $attachment, $ticketId);
151 305 $driver = Helper::getUploadDriverKey();
152 306
153 307 do_action_ref_array('fluent_support/attachment_uploaded_as_temp_' . $driver, [&$attachment, $ticketId]);
@@ -155,9 +309,9 @@
155 309 continue;
156 310 }
157 311 }
158 312
159 - return $imageType == 'direct_paste' ? $full_path : $attachments;
313 + return $imageType == 'direct_paste' ? $directPasteUrl : $attachments;
160 314 }
161 315
162 316 public function uploadImage(Request $request)
163 317 {
@@ -162,15 +316,23 @@
162 316 public function uploadImage(Request $request)
163 317 {
164 318 $images = $request->files();
165 319 $ticketId = $this->resolveTicketId($request);
166 - $this->isValidImageType($images);
167 320
321 + if ($accessError = $this->checkTicketAccess($ticketId)) {
322 + return $accessError;
323 + }
324 +
325 + $validationError = $this->isValidImageType($images);
326 + if ($validationError) {
327 + return $validationError;
328 + }
329 +
168 330 try {
169 331 $uploadedFiles = UploadService::handleUploadToLocal($ticketId, $images);
170 332 } catch (\Exception $e) {
171 333 return $this->sendError([
172 - 'message' => $e->getMessage(),
334 + 'message' => Helper::getSafeErrorMessage($e),
173 335 ]);
174 336 }
175 337
176 338 return [
@@ -175,19 +337,55 @@
175 337
176 338 return [
177 339 'images' => $uploadedFiles,
178 340 ];
179 -
180 341 }
181 342
182 - private function isValidImageType($image)
343 + private function isValidImageType($images)
183 344 {
184 - $imageType = $image['image']->getClientOriginalExtension();
185 - $supportedTypes = ['gif', 'ief', 'jpeg', 'webp', 'pjpeg', 'ktx', 'png'];
345 + if (empty($images['image'])) {
346 + return $this->sendError([
347 + 'message' => __('No image file provided.', 'fluent-support'),
348 + ]);
349 + }
186 350
187 - if (! in_array($imageType, $supportedTypes)) {
351 + $file = $images['image'];
352 + $tempPath = $file->getPathname();
353 + $extension = strtolower($file->getClientOriginalExtension());
354 + $allowedExtensions = ['gif', 'ief', 'jpeg', 'jpg', 'webp', 'pjpeg', 'ktx', 'png'];
355 +
356 + if (!in_array($extension, $allowedExtensions)) {
188 357 return $this->sendError([
189 - 'message' => 'Invalid image file.',
358 + 'message' => __('Invalid image file type.', 'fluent-support'),
190 359 ]);
191 360 }
361 +
362 + $allowedMimes = Helper::getMimeGroups()['images']['mimes'];
363 + $realMime = $this->detectMimeType($tempPath);
364 +
365 + if (!$realMime || !in_array($realMime, $allowedMimes)) {
366 + return $this->sendError([
367 + 'message' => __('File content does not match the image type.', 'fluent-support'),
368 + ]);
369 + }
370 +
371 + return null;
372 + }
373 +
374 + private function detectMimeType($filePath)
375 + {
376 + if (function_exists('finfo_open')) {
377 + $finfo = finfo_open(FILEINFO_MIME_TYPE);
378 + $mime = finfo_file($finfo, $filePath);
379 + finfo_close($finfo);
380 + return $mime;
381 + }
382 +
383 + if (function_exists('mime_content_type')) {
384 + return mime_content_type($filePath);
385 + }
386 +
387 + // getimagesize works for standard image formats as last resort
388 + $imageInfo = @getimagesize($filePath);
389 + return $imageInfo ? $imageInfo['mime'] : false;
192 390 }
193 391 }