false, 'error' => 'Authentication required']); exit; } // Get attachment ID $attachmentId = isset($_GET['id']) ? (int)$_GET['id'] : 0; if ($attachmentId <= 0 || (string)$attachmentId !== (string)($_GET['id'] ?? '')) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']); exit; } try { $attachmentModel = new AttachmentModel(Database::getConnection()); // 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 = Database::getConnection(); $ticketModel = new TicketModel($conn); $ticket = $ticketModel->getTicketById($attachment['ticket_id']); if (!$ticket) { 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'])) { 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 . DIRECTORY_SEPARATOR) !== 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']); $fileSize = filesize($filePath); // Parse a single-range "Range: bytes=start-end" request header (RFC 7233). // Multi-range requests aren't supported; they fall through to a full 200 response. $rangeStart = 0; $rangeEnd = $fileSize - 1; $isRangeRequest = false; if (isset($_SERVER['HTTP_RANGE']) && preg_match('/^bytes=(\d*)-(\d*)$/', trim($_SERVER['HTTP_RANGE']), $m)) { if ($m[1] === '' && $m[2] === '') { // Malformed ("bytes=-") — ignore and serve the full file. } elseif ($m[1] === '') { // Suffix range: last N bytes $suffixLength = (int)$m[2]; $rangeStart = max(0, $fileSize - $suffixLength); $rangeEnd = $fileSize - 1; $isRangeRequest = true; } else { $rangeStart = (int)$m[1]; $rangeEnd = ($m[2] === '') ? $fileSize - 1 : min((int)$m[2], $fileSize - 1); $isRangeRequest = true; } if ($isRangeRequest && ($rangeStart > $rangeEnd || $rangeStart >= $fileSize)) { http_response_code(416); header('Content-Range: bytes */' . $fileSize); exit; } } $rangeLength = $rangeEnd - $rangeStart + 1; header('Accept-Ranges: bytes'); header('Content-Type: ' . $attachment['mime_type']); header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"'); header('Cache-Control: private, max-age=3600'); header('X-Content-Type-Options: nosniff'); if ($isRangeRequest) { http_response_code(206); header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileSize); } header('Content-Length: ' . $rangeLength); // 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; } fseek($handle, $rangeStart); $remaining = $rangeLength; $chunkSize = 8192; while ($remaining > 0 && !feof($handle)) { $read = ($remaining < $chunkSize) ? $remaining : $chunkSize; $data = fread($handle, $read); if ($data === false) { break; } echo $data; flush(); $remaining -= strlen($data); } 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; }