using FruitBank.Common.Entities; using Nop.Plugin.Misc.FruitBankPlugin.Domains.DataLayer; using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Security.Cryptography; using System.Threading.Tasks; namespace Nop.Plugin.Misc.FruitBankPlugin.Services.FileStorage { /// /// Generic file storage service with compression, hash calculation, and duplicate detection /// public class FileStorageService { private readonly IFileStorageProvider _storageProvider; private readonly FruitBankDbContext _dbContext; // File extensions that are already compressed (don't GZip these) private static readonly HashSet CompressedExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", // Images ".pdf", // PDFs ".zip", ".rar", ".7z", ".gz", ".bz2", // Archives ".mp4", ".avi", ".mov", ".mkv", // Videos ".mp3", ".flac", ".aac", ".ogg" // Audio }; public FileStorageService(IFileStorageProvider storageProvider, FruitBankDbContext dbContext) { _storageProvider = storageProvider ?? throw new ArgumentNullException(nameof(storageProvider)); _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); } /// /// Saves a file with optional compression, hash calculation, and duplicate detection /// /// The file stream to save /// Original filename with extension /// User ID for path organization /// Feature name (e.g., "ShippingDocumentProcessing") /// Entity type (e.g., "ShippingDocuments") /// Entity ID /// Optional raw text content (for AI-extracted documents) /// If true, checks if file already exists by hash /// Created or existing Files entity with ID public async Task SaveFileAsync( Stream fileStream, string fileName, int userId, string featureName, string entityType, int entityId, string rawText = null, bool checkForDuplicates = true) { if (fileStream == null) throw new ArgumentNullException(nameof(fileStream)); if (string.IsNullOrWhiteSpace(fileName)) throw new ArgumentNullException(nameof(fileName)); // ✅ STEP 1: Calculate file hash from original stream string fileHash = await CalculateFileHashAsync(fileStream); fileStream.Position = 0; // Reset stream position after hashing Console.WriteLine($"📝 File hash calculated: {fileHash}"); // ✅ STEP 2: Check for duplicate file by hash if (checkForDuplicates) { var existingFile = await _dbContext.Files .GetAll() .FirstOrDefaultAsync(f => f.FileHash == fileHash); if (existingFile != null) { Console.WriteLine($"♻️ Duplicate file detected! Reusing existing file ID: {existingFile.Id}"); return existingFile; // Return existing file instead of creating new one } } // ✅ STEP 3: Create database record first to get ID var fileExtension = Path.GetExtension(fileName); var fileEntity = new Files { FileName = Path.GetFileNameWithoutExtension(fileName), FileExtension = fileExtension, RawText = rawText, FileHash = fileHash, // ✅ Store the hash Created = DateTime.UtcNow, Modified = DateTime.UtcNow, IsCompressed = !IsAlreadyCompressed(fileExtension) }; await _dbContext.Files.InsertAsync(fileEntity); Console.WriteLine($"✅ File record created - ID: {fileEntity.Id}, Hash: {fileHash}"); // ✅ STEP 4: Build storage path with file ID var fileNameWithId = $"{fileEntity.FileName}_{fileEntity.Id}{fileExtension}"; var relativePath = BuildRelativePath(userId, featureName, entityType, entityId, fileNameWithId); // ✅ STEP 5: Determine if file should be compressed bool shouldCompress = !IsAlreadyCompressed(fileExtension); try { Stream streamToSave = fileStream; // Compress if needed if (shouldCompress) { streamToSave = await CompressStreamAsync(fileStream); // Update filename to indicate compression fileNameWithId += ".gz"; relativePath += ".gz"; } // Save to storage provider await _storageProvider.SaveFileAsync(streamToSave, relativePath); // Dispose compressed stream if we created one if (shouldCompress && streamToSave != fileStream) { await streamToSave.DisposeAsync(); } Console.WriteLine($"💾 File saved: {relativePath} (Compressed: {shouldCompress})"); return fileEntity; } catch (Exception ex) { // Rollback database record if file save fails await _dbContext.Files.DeleteAsync(fileEntity); Console.Error.WriteLine($"❌ Error saving file: {ex.Message}"); throw; } } /// /// Check if a file with this hash already exists /// public async Task FindFileByHashAsync(string fileHash) { return await _dbContext.Files .GetAll() .FirstOrDefaultAsync(f => f.FileHash == fileHash); } /// /// Get all files with the same hash (duplicates) /// public async Task> FindDuplicateFilesByHashAsync(string fileHash) { return await _dbContext.Files .GetAll() .Where(f => f.FileHash == fileHash) .ToListAsync(); } /// /// Calculate SHA256 hash from stream /// private async Task CalculateFileHashAsync(Stream stream) { using (var sha256 = SHA256.Create()) { var hashBytes = await Task.Run(() => sha256.ComputeHash(stream)); return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); } } /// /// Retrieves a file by ID with automatic decompression /// public async Task<(Stream FileStream, Files FileInfo)> GetFileByIdAsync( int fileId, int userId, string featureName, string entityType, int entityId) { // Get file record from database var fileEntity = await _dbContext.Files.GetByIdAsync(fileId); if (fileEntity == null) throw new FileNotFoundException($"File with ID {fileId} not found in database"); // Build path var fileNameWithId = $"{fileEntity.FileName}_{fileEntity.Id}{fileEntity.FileExtension}"; var isCompressed = !IsAlreadyCompressed(fileEntity.FileExtension); if (isCompressed) { fileNameWithId += ".gz"; } var relativePath = BuildRelativePath(userId, featureName, entityType, entityId, fileNameWithId); // Get file from storage var fileStream = await _storageProvider.GetFileAsync(relativePath); // Decompress if needed if (isCompressed) { var decompressedStream = await DecompressStreamAsync(fileStream); await fileStream.DisposeAsync(); fileStream = decompressedStream; } return (fileStream, fileEntity); } /// /// Gets all files from database /// public async Task> GetAllFilesAsync() { return await _dbContext.Files.GetAll().ToListAsync(); } /// /// Searches files by filename, hash, or raw text content /// public async Task> SearchFilesAsync(string searchTerm) { if (string.IsNullOrWhiteSpace(searchTerm)) return await GetAllFilesAsync(); var allFiles = await _dbContext.Files.GetAll().ToListAsync(); var results = allFiles.Where(f => // Search in filename (!string.IsNullOrEmpty(f.FileName) && f.FileName.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) || // Search in file extension (!string.IsNullOrEmpty(f.FileExtension) && f.FileExtension.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) || // Search in file hash (!string.IsNullOrEmpty(f.FileHash) && f.FileHash.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) || // Full-text search in RawText (only if RawText is not null) (!string.IsNullOrEmpty(f.RawText) && f.RawText.Contains(searchTerm, StringComparison.OrdinalIgnoreCase)) ).ToList(); Console.WriteLine($"🔍 Search for '{searchTerm}' returned {results.Count} results"); return results; } /// /// Adds or updates a file record in the database /// public async Task AddOrUpdateFileAsync(Files fileEntity) { if (fileEntity == null) throw new ArgumentNullException(nameof(fileEntity)); if (fileEntity.Id > 0) { // Update existing fileEntity.Modified = DateTime.UtcNow; await _dbContext.Files.UpdateAsync(fileEntity); } else { // Add new fileEntity.Created = DateTime.UtcNow; fileEntity.Modified = DateTime.UtcNow; await _dbContext.Files.InsertAsync(fileEntity); } return fileEntity; } /// /// Deletes a file from both storage and database /// public async Task DeleteFileAsync( int fileId, int userId, string featureName, string entityType, int entityId) { var fileEntity = await _dbContext.Files.GetByIdAsync(fileId); if (fileEntity == null) return false; // Build path var fileNameWithId = $"{fileEntity.FileName}_{fileEntity.Id}{fileEntity.FileExtension}"; var isCompressed = !IsAlreadyCompressed(fileEntity.FileExtension); if (isCompressed) { fileNameWithId += ".gz"; } var relativePath = BuildRelativePath(userId, featureName, entityType, entityId, fileNameWithId); // Delete from storage await _storageProvider.DeleteFileAsync(relativePath); // Delete from database await _dbContext.Files.DeleteAsync(fileEntity); return true; } #region Private Helper Methods /// /// Builds the relative storage path /// private string BuildRelativePath(int userId, string featureName, string entityType, int entityId, string fileName) { return Path.Combine( userId.ToString(), featureName, $"{entityType}-{entityId}", fileName ); } /// /// Checks if a file extension represents an already-compressed format /// private bool IsAlreadyCompressed(string extension) { return CompressedExtensions.Contains(extension); } /// /// Compresses a stream using GZip /// private async Task CompressStreamAsync(Stream inputStream) { var compressedStream = new MemoryStream(); using (var gzipStream = new GZipStream(compressedStream, CompressionMode.Compress, leaveOpen: true)) { await inputStream.CopyToAsync(gzipStream); } compressedStream.Position = 0; return compressedStream; } /// /// Decompresses a GZip stream /// private async Task DecompressStreamAsync(Stream compressedStream) { var decompressedStream = new MemoryStream(); using (var gzipStream = new GZipStream(compressedStream, CompressionMode.Decompress, leaveOpen: true)) { await gzipStream.CopyToAsync(decompressedStream); } decompressedStream.Position = 0; return decompressedStream; } #endregion } }