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 +344 -29 1.4.5 → 2.4.5 View file →
@@ -3,74 +3,389 @@
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\App\Services\Includes\FileSystem;
10 -use FluentSupport\Framework\Request\Request;
11 +use FluentSupport\Framework\Http\Request\Request;
12 +use FluentSupport\App\Services\Includes\UploadService;
11 13
14 +/**
15 + * UploaderController class is responsible for uploading file
16 + * @package FluentSupport\App\Http\Controllers
17 + *
18 + * @version 1.0.0
19 + */
12 20 class UploaderController extends Controller
13 21 {
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 + /**
30 + * uploadTicketFiles method will upload all the attached file in a ticket
31 + * @param Request $request
32 + * @return array[]
33 + * @throws \FluentSupport\Framework\Validator\ValidationException
34 + */
14 35 public function uploadTicketFiles(Request $request)
15 36 {
16 37 $settings = (new Settings())->globalBusinessSettings();
17 - $maxFileSize = absint($settings['max_file_size']);
38 + $maxFileSize = floatval($settings['max_file_size']);
39 + $maxFileUpload = intval($settings['max_file_upload']);
18 40 $mimeHeadings = Helper::getAcceptedMimeHeadings();
41 + $maxSizeBytes = $maxFileSize * 1024;
42 + $imageType = $request->type ? $request->type : null;
19 43
20 - $maxSizeBytes = $maxFileSize * 1024;
44 + $files = $request->files();
21 45
22 - $files = $this->validate($this->request->files(), [
23 - 'file' => 'max:' . $maxSizeBytes . '|mimetypes:' . implode(',', Helper::ticketAcceptedFileMiles())
24 - ], [
46 + if ($partsError = $this->rejectUnexpectedFileParts($files)) {
47 + return $partsError;
48 + }
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 +
79 + try {
80 + $uploadedFiles = UploadService::handleTempFileUpload($files);
81 + } catch (\Exception $e) {
82 + return $this->sendError([
83 + 'message' => Helper::getSafeErrorMessage($e),
84 + ]);
85 + }
86 +
87 + if (is_wp_error($uploadedFiles)) {
88 + return $this->sendError([
89 + 'message' => $uploadedFiles->get_error_message(),
90 + ]);
91 + }
92 +
93 + $attachmentHashes = $this->createAttachmentRecords($uploadedFiles, $ticketId, $person, $imageType);
94 +
95 + return [
96 + 'attachments' => $attachmentHashes,
97 + ];
98 + }
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 +
165 + private function validateUploadedFiles($files, $maxSizeBytes, $mimeHeadings, $maxFileSize)
166 + {
167 + $validationRules = [
168 + 'file' => 'max:' . $maxSizeBytes . '|mimetypes:' . implode(',', Helper::ticketAcceptedFileMiles()),
169 + ];
170 +
171 + $validationMessages = [
172 + // translators: %s is a comma-separated list of allowed file types (e.g., "jpg, png, pdf")
25 173 'file.mimetypes' => sprintf(__('Only %s files are allowed.', 'fluent-support'), implode(', ', $mimeHeadings)),
26 - 'file.max' => sprintf(__('The file can not be more than %dMB. Please upload somewhere like dropbox/google drive and paste the link in the response', 'fluent-support'), $maxFileSize)
27 - ]);
174 + // translators: %.01f is the maximum file size in megabytes
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),
176 + ];
28 177
29 - $ticketId = $request->get('ticket_id');
178 + $this->validate($files, $validationRules, $validationMessages);
179 + }
30 180
31 - if ($ticketId == 'undefined') {
32 - $ticketId = NULL;
181 + private function resolveTicketId($request)
182 + {
183 + $ticketId = $request->getSafe('ticket_id', 'intval');
184 +
185 + if ($ticketId == 'undefined' || !$ticketId) {
186 + return null;
33 187 }
34 188
35 - if ($ticketId && $request->get('intended_ticket_hash') && Helper::isPublicSignedTicketEnabled()) {
36 - $ticket = Ticket::with(['customer'])->findOrFail($ticketId);
37 - $person = $ticket->customer;
38 - } else {
39 - $person = Helper::getCurrentPerson();
189 + if (Helper::getCurrentAgent()) {
190 + return $ticketId;
40 191 }
41 192
42 - if ($person->person_type == 'customer') {
193 + $ticket = Ticket::wherePublicIdentifier($ticketId)->first();
194 +
195 + return $ticket ? $ticket->id : null;
196 + }
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 +
232 + private function resolvePerson($ticketId, Request $request)
233 + {
234 + $agent = Helper::getCurrentAgent();
235 + if ($agent) {
236 + return $agent;
237 + }
238 +
239 + if ($ticketId && Helper::isPublicSignedTicketEnabled()) {
240 + $intendedTicketHash = $request->getSafe('intended_ticket_hash', 'sanitize_text_field');
241 + if ($intendedTicketHash && $intendedTicketHash != 'undefined') {
242 + $ticket = Ticket::with(['customer'])
243 + ->where('hash', $intendedTicketHash)
244 + ->wherePublicIdentifier($ticketId)
245 + ->first();
246 +
247 + if ($ticket && $ticket->customer) {
248 + return $ticket->customer;
249 + }
250 + }
251 + }
252 +
253 + return Helper::getCurrentPerson();
254 + }
255 +
256 + private function checkPermissionToUploadFile($person)
257 + {
258 + if (!$person) {
259 + return $this->sendError([
260 + 'message' => __('You do not have permission to upload a file', 'fluent-support'),
261 + ]);
262 + }
263 +
264 + if ($person->person_type === 'customer') {
43 265 $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []);
44 266 if (in_array('file_upload', $disabledFields)) {
45 267 return $this->sendError([
46 - 'message' => 'You do not have permission to upload a file'
268 + 'message' => __('You do not have permission to upload a file', 'fluent-support'),
47 269 ]);
48 270 }
49 271 }
272 + }
50 273
51 - $uploadedFiles = FileSystem::setSubDir('ticket_' . $ticketId)->put($files);
274 + private function createAttachmentRecords($uploadedFiles, $ticketId, $person, $imageType)
275 + {
276 + $attachments = [];
277 + $directPasteUrl = null;
52 278
53 - $attachments = [];
54 279 foreach ($uploadedFiles as $file) {
280 + if (empty($file['file_path'])) continue;
55 281
56 282 $fileData = [
57 - 'ticket_id' => $ticketId,
58 - 'person_id' => $person->id,
283 + 'ticket_id' => intval($ticketId) ?: NULL,
284 + 'person_id' => intval($person->id),
59 285 'file_type' => $file['type'],
60 286 'file_path' => $file['file_path'],
61 - 'full_url' => $file['url'],
62 - 'title' => $file['name'],
287 + 'full_url' => esc_url($file['url']),
288 + 'title' => sanitize_file_name($file['name']),
63 289 'driver' => 'local',
64 - 'status' => 'in-active'
290 + 'status' => 'in-active',
291 + 'settings' => [
292 + 'local_temp_path' => $file['file_path'],
293 + ]
65 294 ];
66 295
67 - $attachment = Attachment::create($fileData);
68 - $attachments[] = $attachment->file_hash;
296 + try {
297 + $attachment = Attachment::create($fileData);
298 + $attachments[] = $attachment->file_hash;
299 +
300 + if ($imageType == 'direct_paste') {
301 + $directPasteUrl = $attachment->secureUrl;
302 + }
303 +
304 + do_action('fluent_support/attachment_uploaded_as_temp', $attachment, $ticketId);
305 + $driver = Helper::getUploadDriverKey();
306 +
307 + do_action_ref_array('fluent_support/attachment_uploaded_as_temp_' . $driver, [&$attachment, $ticketId]);
308 + } catch (\Exception $exception) {
309 + continue;
310 + }
69 311 }
70 312
313 + return $imageType == 'direct_paste' ? $directPasteUrl : $attachments;
314 + }
315 +
316 + public function uploadImage(Request $request)
317 + {
318 + $images = $request->files();
319 + $ticketId = $this->resolveTicketId($request);
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 +
330 + try {
331 + $uploadedFiles = UploadService::handleUploadToLocal($ticketId, $images);
332 + } catch (\Exception $e) {
333 + return $this->sendError([
334 + 'message' => Helper::getSafeErrorMessage($e),
335 + ]);
336 + }
337 +
71 338 return [
72 - 'attachments' => $attachments
339 + 'images' => $uploadedFiles,
73 340 ];
341 + }
74 342
343 + private function isValidImageType($images)
344 + {
345 + if (empty($images['image'])) {
346 + return $this->sendError([
347 + 'message' => __('No image file provided.', 'fluent-support'),
348 + ]);
349 + }
350 +
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)) {
357 + return $this->sendError([
358 + 'message' => __('Invalid image file type.', 'fluent-support'),
359 + ]);
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;
75 390 }
76 391 }