325 lines
13 KiB
C#
325 lines
13 KiB
C#
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using PowderCoating.Application.Interfaces;
|
|
|
|
namespace PowderCoating.Application.Services;
|
|
|
|
/// <summary>
|
|
/// On-premises file storage service that saves, retrieves, and deletes files under the
|
|
/// application's <c>wwwroot/uploads/</c> directory. This service is the legacy storage path for
|
|
/// self-hosted deployments; cloud-hosted tenants use <see cref="AzureBlobStorageService"/> 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.
|
|
/// </summary>
|
|
public class FileService : IFileService
|
|
{
|
|
private const string UploadsRootFolder = "uploads";
|
|
private readonly IWebHostEnvironment _environment;
|
|
private readonly ILogger<FileService> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes the service with the hosting environment (provides <c>WebRootPath</c>) and logger.
|
|
/// </summary>
|
|
public FileService(IWebHostEnvironment environment, ILogger<FileService> logger)
|
|
{
|
|
_environment = environment;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates and saves an uploaded file to the specified subfolder under <c>wwwroot/uploads/</c>.
|
|
/// Validation order: null/empty check, size limit, then extension allowlist. The original file
|
|
/// name is sanitised with <see cref="Path.GetFileName"/> 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 <c>wwwroot/uploads/</c> before
|
|
/// any file system access occurs. Returns a relative path (from <c>wwwroot</c>) suitable for
|
|
/// storing in the database.
|
|
/// </summary>
|
|
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.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a file given its relative path from <c>wwwroot</c>.
|
|
/// 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
|
|
/// <c>wwwroot/uploads/</c>; paths outside that root are rejected.
|
|
/// </summary>
|
|
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.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>wwwroot/uploads/</c> path but
|
|
/// are otherwise not directly exposed through the static-files middleware.
|
|
/// </summary>
|
|
public async Task<(bool Success, byte[] FileContent, string ContentType, string ErrorMessage)> GetFileAsync(string filePath)
|
|
{
|
|
try
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filePath))
|
|
{
|
|
return (false, Array.Empty<byte>(), string.Empty, "File path is required.");
|
|
}
|
|
|
|
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out var pathError))
|
|
{
|
|
return (false, Array.Empty<byte>(), string.Empty, pathError);
|
|
}
|
|
|
|
if (!File.Exists(fullPath))
|
|
{
|
|
_logger.LogWarning("File not found: {FilePath}", filePath);
|
|
return (false, Array.Empty<byte>(), 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<byte>(), string.Empty, "An error occurred while retrieving the file.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether a file exists at the given <c>wwwroot/uploads/</c>-relative path without reading it.
|
|
/// Used by views and controllers to conditionally show download links only when the file is present.
|
|
/// </summary>
|
|
public bool FileExists(string filePath)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(filePath))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!TryResolveLegacyUploadPath(filePath, out var fullPath, out _))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return File.Exists(fullPath);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps a file name or path to its MIME content type based on the extension.
|
|
/// Falls back to <c>application/octet-stream</c> for unrecognised extensions so the browser
|
|
/// triggers a download rather than attempting to render an unknown format inline.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|