Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,215 @@
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 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. 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.
var uploadPath = Path.Combine(_environment.WebRootPath, "uploads", subfolder);
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("uploads", subfolder, 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 converted to an absolute path with
/// <see cref="Path.Combine"/> rather than string concatenation to prevent directory traversal.
/// </summary>
public async Task<(bool Success, string ErrorMessage)> DeleteFileAsync(string filePath)
{
try
{
if (string.IsNullOrWhiteSpace(filePath))
{
return (false, "File path is required.");
}
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
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 outside <c>wwwroot</c> (or otherwise not directly
/// accessible via the static-files middleware) so controllers can stream them as file responses.
/// </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.");
}
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
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</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;
}
var fullPath = Path.Combine(_environment.WebRootPath, filePath.Replace('/', Path.DirectorySeparatorChar));
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"
};
}
}