namespace Books.Api.Infrastructure.FileStorage; /// /// Local file system storage implementation. /// Suitable for development and small deployments. /// For production, consider Azure Blob Storage or AWS S3. /// public class LocalFileStorageService : IFileStorageService { private readonly string _basePath; private readonly string _baseUrl; public LocalFileStorageService(IConfiguration configuration) { _basePath = configuration["FileStorage:LocalPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "uploads"); _baseUrl = configuration["FileStorage:BaseUrl"] ?? "/api/attachments"; // Ensure base directory exists if (!Directory.Exists(_basePath)) { Directory.CreateDirectory(_basePath); } } public async Task StoreAsync( string companyId, string fileName, string contentType, Stream content, CancellationToken cancellationToken = default) { // Create company directory var companyPath = Path.Combine(_basePath, companyId); if (!Directory.Exists(companyPath)) { Directory.CreateDirectory(companyPath); } // Generate unique filename to prevent collisions var extension = Path.GetExtension(fileName); var sanitizedName = SanitizeFileName(Path.GetFileNameWithoutExtension(fileName)); var uniqueId = Guid.NewGuid().ToString("N")[..8]; var storedFileName = $"{sanitizedName}_{uniqueId}{extension}"; var filePath = Path.Combine(companyPath, storedFileName); // Store file await using var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write); await content.CopyToAsync(fileStream, cancellationToken); // Storage path is relative to base (company/filename) var storagePath = $"{companyId}/{storedFileName}"; return new StorageResult { StoragePath = storagePath, StoredFileName = storedFileName, FileSize = fileStream.Length }; } public Task GetAsync(string storagePath, CancellationToken cancellationToken = default) { var filePath = Path.Combine(_basePath, storagePath); if (!File.Exists(filePath)) return Task.FromResult(null)!; var fileInfo = new FileInfo(filePath); var contentType = GetContentType(fileInfo.Extension); // Open for reading (caller is responsible for disposing) var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); return Task.FromResult(new FileResult { Content = stream, ContentType = contentType, FileName = fileInfo.Name, FileSize = fileInfo.Length }); } public Task DeleteAsync(string storagePath, CancellationToken cancellationToken = default) { var filePath = Path.Combine(_basePath, storagePath); if (File.Exists(filePath)) { File.Delete(filePath); } return Task.CompletedTask; } public string GetBasePath() => _basePath; public string GetDownloadUrl(string storagePath, TimeSpan? expiresIn = null) { // For local storage, return API endpoint URL // The actual download is handled by a controller // Encode each path segment individually to avoid double-encoding path separators var encodedPath = string.Join("/", storagePath.Split('/').Select(Uri.EscapeDataString)); return $"{_baseUrl}/{encodedPath}"; } private static string SanitizeFileName(string fileName) { // Remove invalid characters var invalid = Path.GetInvalidFileNameChars(); var sanitized = string.Join("", fileName.Split(invalid)); // Limit length if (sanitized.Length > 50) sanitized = sanitized[..50]; // Default if empty if (string.IsNullOrWhiteSpace(sanitized)) sanitized = "attachment"; return sanitized; } private static string GetContentType(string extension) { return extension.ToLowerInvariant() switch { ".pdf" => "application/pdf", ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".webp" => "image/webp", ".doc" => "application/msword", ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xls" => "application/vnd.ms-excel", ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", _ => "application/octet-stream" }; } }