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
}
}