using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using PowderCoating.Application.Interfaces; namespace PowderCoating.Application.Services; /// /// On-premises file storage service that saves, retrieves, and deletes files under the /// application's wwwroot/uploads/ directory. This service is the legacy storage path for /// self-hosted deployments; cloud-hosted tenants use instead. /// All file names are prefixed with a new GUID to prevent collisions and block path traversal /// attacks that embed directory separators in the original file name. /// public class FileService : IFileService { private const string UploadsRootFolder = "uploads"; private readonly IWebHostEnvironment _environment; private readonly ILogger _logger; /// /// Initializes the service with the hosting environment (provides WebRootPath) and logger. /// public FileService(IWebHostEnvironment environment, ILogger logger) { _environment = environment; _logger = logger; } /// /// Validates and saves an uploaded file to the specified subfolder under wwwroot/uploads/. /// Validation order: null/empty check, size limit, then extension allowlist. The original file /// name is sanitised with to strip any directory components before /// prepending the GUID prefix, preventing path traversal if the browser supplies a name with /// slashes. The target subfolder is resolved and confined under wwwroot/uploads/ before /// any file system access occurs. Returns a relative path (from wwwroot) suitable for /// storing in the database. /// public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveFileAsync( IFormFile file, string subfolder, string[] allowedExtensions, long maxFileSize) { try { // Validate file if (file == null || file.Length == 0) { return (false, string.Empty, "No file was uploaded."); } // Validate file size if (file.Length > maxFileSize) { var maxSizeMB = maxFileSize / (1024 * 1024); return (false, string.Empty, $"File size exceeds the maximum allowed size of {maxSizeMB} MB."); } // Validate file extension var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension)) { var allowedExts = string.Join(", ", allowedExtensions); return (false, string.Empty, $"Invalid file type. Allowed types: {allowedExts}"); } // Create upload directory if it doesn't exist // NOTE: WebRootPath is read-only on Azure Linux App Service; this service is legacy // and should only be called for on-premises deployments. New uploads use Azure Blob. if (!TryResolveUploadSubfolder(subfolder, out var uploadPath, out var relativeSubfolder, out var subfolderError)) { return (false, string.Empty, subfolderError); } if (!Directory.Exists(uploadPath)) { try { Directory.CreateDirectory(uploadPath); _logger.LogInformation("Created upload directory: {UploadPath}", uploadPath); } catch (Exception ex) { _logger.LogWarning(ex, "Could not create upload directory {UploadPath} — filesystem may be read-only", uploadPath); return (false, string.Empty, "File storage is not available in this environment."); } } // Generate unique filename — strip directory components from the original name // to prevent path traversal if the browser sends a filename with slashes. var safeOriginalName = Path.GetFileName(file.FileName); var uniqueFileName = $"{Guid.NewGuid()}-{safeOriginalName}"; var filePath = Path.Combine(uploadPath, uniqueFileName); // Save the file using (var stream = new FileStream(filePath, FileMode.Create)) { await file.CopyToAsync(stream); } // Return relative path from wwwroot var relativePath = Path.Combine(UploadsRootFolder, relativeSubfolder, uniqueFileName).Replace("\\", "/"); _logger.LogInformation("File saved successfully: {FilePath}", relativePath); return (true, relativePath, string.Empty); } catch (Exception ex) { _logger.LogError(ex, "Error saving file: {FileName}", file?.FileName); return (false, string.Empty, "An error occurred while saving the file."); } } /// /// Deletes a file given its relative path from wwwroot. /// Returns success if the file does not exist (idempotent) so that callers do not need to check /// existence before calling. The relative path is normalized and must remain under /// wwwroot/uploads/; paths outside that root are rejected. /// public async Task<(bool Success, string ErrorMessage)> DeleteFileAsync(string filePath) { try { if (string.IsNullOrWhiteSpace(filePath)) { return (false, "File path is required."); } if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError)) { return (false, pathError); } if (!File.Exists(fullPath)) { _logger.LogWarning("File not found for deletion: {FilePath}", filePath); return (true, string.Empty); // Consider it successful if file doesn't exist } await Task.Run(() => File.Delete(fullPath)); _logger.LogInformation("File deleted successfully: {FilePath}", filePath); return (true, string.Empty); } catch (Exception ex) { _logger.LogError(ex, "Error deleting file: {FilePath}", filePath); return (false, "An error occurred while deleting the file."); } } /// /// Reads a file from disk and returns its raw bytes along with a derived MIME content type. /// Intended for serving files that are stored under the legacy wwwroot/uploads/ path but /// are otherwise not directly exposed through the static-files middleware. /// public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetFileAsync(string filePath) { try { if (string.IsNullOrWhiteSpace(filePath)) { return (false, Array.Empty(), string.Empty, "File path is required."); } if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError)) { return (false, Array.Empty(), string.Empty, pathError); } if (!File.Exists(fullPath)) { _logger.LogWarning("File not found: {FilePath}", filePath); return (false, Array.Empty(), string.Empty, "File not found."); } var fileBytes = await File.ReadAllBytesAsync(fullPath); var contentType = GetContentType(filePath); return (true, fileBytes, contentType, string.Empty); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving file: {FilePath}", filePath); return (false, Array.Empty(), string.Empty, "An error occurred while retrieving the file."); } } /// /// Checks whether a file exists at the given wwwroot/uploads/-relative path without reading it. /// Used by views and controllers to conditionally show download links only when the file is present. /// public bool FileExists(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) { return false; } if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out _)) { return false; } return File.Exists(fullPath); } /// /// Maps a file name or path to its MIME content type based on the extension. /// Falls back to application/octet-stream for unrecognised extensions so the browser /// triggers a download rather than attempting to render an unknown format inline. /// public string GetContentType(string fileName) { var extension = Path.GetExtension(fileName).ToLowerInvariant(); return extension switch { ".pdf" => "application/pdf", ".doc" => "application/msword", ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".xls" => "application/vnd.ms-excel", ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".jpg" or ".jpeg" => "image/jpeg", ".png" => "image/png", ".gif" => "image/gif", ".webp" => "image/webp", _ => "application/octet-stream" }; } private bool TryResolveUploadSubfolder( string subfolder, out string uploadPath, out string relativeSubfolder, out string errorMessage) { uploadPath = string.Empty; relativeSubfolder = string.Empty; errorMessage = string.Empty; if (string.IsNullOrWhiteSpace(subfolder)) { errorMessage = "Upload subfolder is required."; return false; } if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage)) { return false; } var normalizedSubfolder = subfolder.Replace('\\', '/').Trim('/'); var resolvedPath = Path.GetFullPath( Path.Combine(uploadsRoot, normalizedSubfolder.Replace('/', Path.DirectorySeparatorChar))); if (!IsWithinDirectory(resolvedPath, uploadsRoot)) { errorMessage = "Invalid upload subfolder."; _logger.LogWarning("Rejected upload subfolder outside uploads root: {Subfolder}", subfolder); return false; } relativeSubfolder = Path.GetRelativePath(uploadsRoot, resolvedPath).Replace("\\", "/"); uploadPath = resolvedPath; return true; } private bool TryResolveLegacyUploadPath(string filePath, out string fullPath, out string errorMessage) { fullPath = string.Empty; errorMessage = string.Empty; if (!TryGetUploadsRootPath(out var uploadsRoot, out errorMessage)) { return false; } var normalizedRelativePath = filePath.Replace('\\', '/').TrimStart('/'); if (!normalizedRelativePath.StartsWith($"{UploadsRootFolder}/", StringComparison.OrdinalIgnoreCase)) { errorMessage = "Invalid file path."; _logger.LogWarning("Rejected legacy file path outside uploads root: {FilePath}", filePath); return false; } var resolvedPath = Path.GetFullPath( Path.Combine(_environment.WebRootPath, normalizedRelativePath.Replace('/', Path.DirectorySeparatorChar))); if (!IsWithinDirectory(resolvedPath, uploadsRoot)) { errorMessage = "Invalid file path."; _logger.LogWarning("Rejected path traversal attempt for legacy file path: {FilePath}", filePath); return false; } fullPath = resolvedPath; return true; } private bool TryGetUploadsRootPath(out string uploadsRoot, out string errorMessage) { uploadsRoot = string.Empty; errorMessage = string.Empty; if (string.IsNullOrWhiteSpace(_environment.WebRootPath)) { errorMessage = "File storage is not available in this environment."; _logger.LogWarning("WebRootPath is not configured for the legacy file service."); return false; } uploadsRoot = Path.GetFullPath(Path.Combine(_environment.WebRootPath, UploadsRootFolder)); return true; } private static bool IsWithinDirectory(string candidatePath, string rootPath) { var normalizedRoot = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; return candidatePath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase); } }