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 +221 -25 1.10.2 → 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()),
@@ -76,15 +180,61 @@
76 180
77 181 private function resolveTicketId($request)
78 182 {
79 183 $ticketId = $request->getSafe('ticket_id', 'intval');
80 - 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;
81 196 }
82 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 +
83 232 private function resolvePerson($ticketId, Request $request)
84 233 {
85 - if ($request->get('is_agent') == 'yes') {
86 - return Helper::getCurrentAgent();
234 + $agent = Helper::getCurrentAgent();
235 + if ($agent) {
236 + return $agent;
87 237 }
88 238
89 239 if ($ticketId && Helper::isPublicSignedTicketEnabled()) {
90 240 $intendedTicketHash = $request->getSafe('intended_ticket_hash', 'sanitize_text_field');
@@ -90,9 +240,10 @@
90 240 $intendedTicketHash = $request->getSafe('intended_ticket_hash', 'sanitize_text_field');
91 241 if ($intendedTicketHash && $intendedTicketHash != 'undefined') {
92 242 $ticket = Ticket::with(['customer'])
93 243 ->where('hash', $intendedTicketHash)
94 - ->find($ticketId);
244 + ->wherePublicIdentifier($ticketId)
245 + ->first();
95 246
96 247 if ($ticket && $ticket->customer) {
97 248 return $ticket->customer;
98 249 }
@@ -122,9 +273,9 @@
122 273
123 274 private function createAttachmentRecords($uploadedFiles, $ticketId, $person, $imageType)
124 275 {
125 276 $attachments = [];
126 - $full_path = null;
277 + $directPasteUrl = null;
127 278
128 279 foreach ($uploadedFiles as $file) {
129 280 if (empty($file['file_path'])) continue;
130 281
@@ -141,15 +292,16 @@
141 292 'local_temp_path' => $file['file_path'],
142 293 ]
143 294 ];
144 295
145 - if($imageType == 'direct_paste'){
146 - $full_path = esc_url($file['url']);
147 - }
148 -
149 296 try {
150 297 $attachment = Attachment::create($fileData);
151 298 $attachments[] = $attachment->file_hash;
299 +
300 + if ($imageType == 'direct_paste') {
301 + $directPasteUrl = $attachment->secureUrl;
302 + }
303 +
152 304 do_action('fluent_support/attachment_uploaded_as_temp', $attachment, $ticketId);
153 305 $driver = Helper::getUploadDriverKey();
154 306
155 307 do_action_ref_array('fluent_support/attachment_uploaded_as_temp_' . $driver, [&$attachment, $ticketId]);
@@ -157,9 +309,9 @@
157 309 continue;
158 310 }
159 311 }
160 312
161 - return $imageType == 'direct_paste' ? $full_path : $attachments;
313 + return $imageType == 'direct_paste' ? $directPasteUrl : $attachments;
162 314 }
163 315
164 316 public function uploadImage(Request $request)
165 317 {
@@ -164,15 +316,23 @@
164 316 public function uploadImage(Request $request)
165 317 {
166 318 $images = $request->files();
167 319 $ticketId = $this->resolveTicketId($request);
168 - $this->isValidImageType($images);
169 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 +
170 330 try {
171 331 $uploadedFiles = UploadService::handleUploadToLocal($ticketId, $images);
172 332 } catch (\Exception $e) {
173 333 return $this->sendError([
174 - 'message' => $e->getMessage(),
334 + 'message' => Helper::getSafeErrorMessage($e),
175 335 ]);
176 336 }
177 337
178 338 return [
@@ -177,19 +337,55 @@
177 337
178 338 return [
179 339 'images' => $uploadedFiles,
180 340 ];
181 -
182 341 }
183 342
184 - private function isValidImageType($image)
343 + private function isValidImageType($images)
185 344 {
186 - $imageType = $image['image']->getClientOriginalExtension();
187 - $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 + }
188 350
189 - 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)) {
190 357 return $this->sendError([
191 - 'message' => 'Invalid image file.',
358 + 'message' => __('Invalid image file type.', 'fluent-support'),
192 359 ]);
193 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;
194 390 }
195 391 }