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
fluent-support / app / Http / Controllers / UploaderController.php

UploaderController.php in Fluent Support – Helpdesk & Customer Support Ticket System 2.4.5, at app/Http/Controllers/UploaderController.php

392 lines 13.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Http\Controllers;
4
5 use FluentSupport\App\Models\Attachment;
6 use FluentSupport\App\Models\Ticket;
7 use FluentSupport\App\Modules\PermissionManager;
8 use FluentSupport\App\Services\CustomerPortalService;
9 use FluentSupport\App\Services\EmailNotification\Settings;
10 use FluentSupport\App\Services\Helper;
11 use FluentSupport\Framework\Http\Request\Request;
12 use FluentSupport\App\Services\Includes\UploadService;
13
14 /**
15 * UploaderController class is responsible for uploading file
16 * @package FluentSupport\App\Http\Controllers
17 *
18 * @version 1.0.0
19 */
20 class UploaderController extends Controller
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 */
35 public function uploadTicketFiles(Request $request)
36 {
37 $settings = (new Settings())->globalBusinessSettings();
38 $maxFileSize = floatval($settings['max_file_size']);
39 $maxFileUpload = intval($settings['max_file_upload']);
40 $mimeHeadings = Helper::getAcceptedMimeHeadings();
41 $maxSizeBytes = $maxFileSize * 1024;
42 $imageType = $request->type ? $request->type : null;
43
44 $files = $request->files();
45
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")
173 'file.mimetypes' => sprintf(__('Only %s files are allowed.', 'fluent-support'), implode(', ', $mimeHeadings)),
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 ];
177
178 $this->validate($files, $validationRules, $validationMessages);
179 }
180
181 private function resolveTicketId($request)
182 {
183 $ticketId = $request->getSafe('ticket_id', 'intval');
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;
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') {
265 $disabledFields = apply_filters('fluent_support/disabled_ticket_fields', []);
266 if (in_array('file_upload', $disabledFields)) {
267 return $this->sendError([
268 'message' => __('You do not have permission to upload a file', 'fluent-support'),
269 ]);
270 }
271 }
272 }
273
274 private function createAttachmentRecords($uploadedFiles, $ticketId, $person, $imageType)
275 {
276 $attachments = [];
277 $directPasteUrl = null;
278
279 foreach ($uploadedFiles as $file) {
280 if (empty($file['file_path'])) continue;
281
282 $fileData = [
283 'ticket_id' => intval($ticketId) ?: NULL,
284 'person_id' => intval($person->id),
285 'file_type' => $file['type'],
286 'file_path' => $file['file_path'],
287 'full_url' => esc_url($file['url']),
288 'title' => sanitize_file_name($file['name']),
289 'driver' => 'local',
290 'status' => 'in-active',
291 'settings' => [
292 'local_temp_path' => $file['file_path'],
293 ]
294 ];
295
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 }
311 }
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
338 return [
339 'images' => $uploadedFiles,
340 ];
341 }
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;
390 }
391 }
392