getTicketById((int)$ticketId); if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) { ResponseHelper::notFound('Ticket not found'); } $attachmentModel = new AttachmentModel($conn); $attachments = $attachmentModel->getAttachments($ticketId); // Add formatted file size and icon to each attachment foreach ($attachments as &$att) { $att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']); $att['icon'] = AttachmentModel::getFileIcon($att['mime_type']); } ResponseHelper::success(['attachments' => $attachments]); } catch (Exception $e) { ResponseHelper::serverError('Failed to load attachments'); } } // Only accept POST requests for uploads if ($_SERVER['REQUEST_METHOD'] !== 'POST') { ResponseHelper::error('Method not allowed', 405); } // Verify CSRF token $csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; if (!CsrfMiddleware::validateToken($csrfToken)) { ResponseHelper::forbidden('Invalid CSRF token'); } // Get ticket ID $ticketId = $_POST['ticket_id'] ?? ''; if (empty($ticketId)) { ResponseHelper::error('Ticket ID is required'); } // Validate ticket ID format (positive integer) if (!preg_match('/^\d+$/', $ticketId)) { ResponseHelper::error('Invalid ticket ID format'); } // Verify user can access the ticket before accepting upload $conn = Database::getConnection(); $ticketModel = new TicketModel($conn); $ticket = $ticketModel->getTicketById((int)$ticketId); if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) { ResponseHelper::notFound('Ticket not found'); } // Check if file was uploaded if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) { ResponseHelper::error('No file uploaded'); } $file = $_FILES['file']; // Check for upload errors if ($file['error'] !== UPLOAD_ERR_OK) { $errorMessages = [ UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive', UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive', UPLOAD_ERR_PARTIAL => 'File was only partially uploaded', UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder', UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', UPLOAD_ERR_EXTENSION => 'File upload stopped by extension' ]; $message = $errorMessages[$file['error']] ?? 'Unknown upload error'; ResponseHelper::error($message); } // Check file size $maxSize = $GLOBALS['config']['MAX_UPLOAD_SIZE'] ?? 10485760; // 10MB default if ($file['size'] > $maxSize) { ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')'); } // Get MIME type $finfo = new finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->file($file['tmp_name']); // Validate file type if (!AttachmentModel::isAllowedType($mimeType)) { ResponseHelper::error('File type not allowed: ' . $mimeType); } // Create upload directory if it doesn't exist $uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; if (!is_dir($uploadDir)) { if (!mkdir($uploadDir, 0755, true)) { ResponseHelper::serverError('Failed to create upload directory'); } } // Create ticket subdirectory — ticketId is validated as digits-only above $ticketDir = $uploadDir . '/' . $ticketId; if (!is_dir($ticketDir)) { if (!mkdir($ticketDir, 0755, true)) { ResponseHelper::serverError('Failed to create ticket upload directory'); } } // Confirm resolved path stays within the upload root (defence-in-depth) $resolvedTicketDir = realpath($ticketDir); if ($resolvedTicketDir === false || strpos($resolvedTicketDir, realpath($uploadDir)) !== 0) { ResponseHelper::error('Invalid upload path'); } // Derive extension from validated MIME type (never from user-supplied filename) // This prevents executable extension attacks (e.g. evil.php disguised as text/plain) $mimeToExt = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'application/pdf' => 'pdf', 'text/plain' => 'txt', 'text/csv' => 'csv', 'application/msword' => 'doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', 'application/vnd.ms-excel' => 'xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', 'application/zip' => 'zip', 'application/x-7z-compressed' => '7z', 'application/x-tar' => 'tar', 'application/gzip' => 'gz', 'application/json' => 'json', 'application/xml' => 'xml', ]; $safeExtension = $mimeToExt[$mimeType] ?? 'bin'; $uniqueFilename = uniqid('att_', true) . '.' . $safeExtension; $targetPath = $ticketDir . '/' . $uniqueFilename; // Move uploaded file if (!move_uploaded_file($file['tmp_name'], $targetPath)) { ResponseHelper::serverError('Failed to move uploaded file'); } // Sanitize original filename $originalFilename = basename($file['name']); $originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename); if (empty($originalFilename)) { $originalFilename = 'attachment' . ($safeExtension ? '.' . $safeExtension : ''); } // Save to database try { $attachmentModel = new AttachmentModel($conn); $attachmentId = $attachmentModel->addAttachment( $ticketId, $uniqueFilename, $originalFilename, $file['size'], $mimeType, $_SESSION['user']['user_id'] ); if (!$attachmentId) { // Clean up file if database insert fails unlink($targetPath); ResponseHelper::serverError('Failed to save attachment record'); } // Log the upload $conn = Database::getConnection(); $auditLog = new AuditLogModel($conn); $auditLog->log( $_SESSION['user']['user_id'], 'attachment_upload', 'ticket_attachments', (string)$attachmentId, [ 'ticket_id' => $ticketId, 'filename' => $originalFilename, 'size' => $file['size'], 'mime_type' => $mimeType ] ); ResponseHelper::created([ 'attachment_id' => $attachmentId, 'filename' => $originalFilename, 'file_size' => $file['size'], 'file_size_formatted' => AttachmentModel::formatFileSize($file['size']), 'mime_type' => $mimeType, 'icon' => AttachmentModel::getFileIcon($mimeType), 'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'], 'uploaded_at' => date('Y-m-d H:i:s') ], 'File uploaded successfully'); } catch (Exception $e) { // Clean up file on error if (file_exists($targetPath)) { unlink($targetPath); } ResponseHelper::serverError('Failed to process attachment'); }