false, 'error' => 'Authentication required']); exit; } // Get attachment ID $attachmentId = $_GET['id'] ?? null; if (!$attachmentId || !is_numeric($attachmentId)) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']); exit; } $attachmentId = (int)$attachmentId; try { $attachmentModel = new AttachmentModel(); // Get attachment details $attachment = $attachmentModel->getAttachment($attachmentId); if (!$attachment) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Attachment not found']); exit; } // Verify the associated ticket exists and user has access $conn = new mysqli( $GLOBALS['config']['DB_HOST'], $GLOBALS['config']['DB_USER'], $GLOBALS['config']['DB_PASS'], $GLOBALS['config']['DB_NAME'] ); if ($conn->connect_error) { http_response_code(500); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Database connection failed']); exit; } $ticketModel = new TicketModel($conn); $ticket = $ticketModel->getTicketById($attachment['ticket_id']); if (!$ticket) { $conn->close(); http_response_code(404); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Associated ticket not found']); exit; } // Check if user has access to this ticket based on visibility settings if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) { $conn->close(); http_response_code(403); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']); exit; } $conn->close(); // Build file path $uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; $filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename']; // Security: Verify the resolved path is within the uploads directory (prevent path traversal) $realUploadDir = realpath($uploadDir); $realFilePath = realpath($filePath); if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir) !== 0) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Access denied']); exit; } // Check if file exists if (!file_exists($realFilePath)) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'File not found on server']); exit; } // Use the validated real path $filePath = $realFilePath; // Determine if we should display inline or force download $inline = isset($_GET['inline']) && $_GET['inline'] === '1'; $inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']; // Set headers $disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment'; // Sanitize filename for Content-Disposition $safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']); header('Content-Type: ' . $attachment['mime_type']); header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"'); header('Content-Length: ' . $attachment['file_size']); header('Cache-Control: private, max-age=3600'); header('X-Content-Type-Options: nosniff'); // Prevent PHP from timing out on large files set_time_limit(0); // Clear output buffer if (ob_get_level()) { ob_end_clean(); } // Stream file $handle = fopen($filePath, 'rb'); if ($handle === false) { http_response_code(500); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Failed to open file']); exit; } while (!feof($handle)) { echo fread($handle, 8192); flush(); } fclose($handle); exit; } catch (Exception $e) { http_response_code(500); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Failed to download attachment']); exit; }