Add UK education verification and security fixes
Features: - Add UK institution recognition (170+ universities) - Add diploma mill detection (100+ blacklisted institutions) - Add education verification service with date plausibility checks - Add local file storage option (no Azure required) - Add default admin user seeding on startup - Enhance Serilog logging with file output Security fixes: - Fix path traversal vulnerability in LocalFileStorageService - Fix open redirect in login endpoint (use LocalRedirect) - Fix password validation message (12 chars, not 6) - Fix login to use HTTP POST endpoint (avoid Blazor cookie issues) Code improvements: - Add CancellationToken propagation to CV parser - Add shared helpers (JsonDefaults, DateHelpers, ScoreThresholds) - Add IUserContextService for user ID extraction - Parallelized company verification in ProcessCVCheckJob - Add 28 unit tests for education verification Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
117
src/TrueCV.Infrastructure/Services/LocalFileStorageService.cs
Normal file
117
src/TrueCV.Infrastructure/Services/LocalFileStorageService.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TrueCV.Application.Interfaces;
|
||||
using TrueCV.Infrastructure.Configuration;
|
||||
|
||||
namespace TrueCV.Infrastructure.Services;
|
||||
|
||||
public sealed class LocalFileStorageService : IFileStorageService
|
||||
{
|
||||
private readonly string _storagePath;
|
||||
private readonly ILogger<LocalFileStorageService> _logger;
|
||||
|
||||
public LocalFileStorageService(
|
||||
IOptions<LocalStorageSettings> settings,
|
||||
ILogger<LocalFileStorageService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_storagePath = settings.Value.StoragePath;
|
||||
|
||||
if (!Directory.Exists(_storagePath))
|
||||
{
|
||||
Directory.CreateDirectory(_storagePath);
|
||||
_logger.LogInformation("Created local storage directory: {Path}", _storagePath);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> UploadAsync(Stream fileStream, string fileName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fileStream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
|
||||
var extension = Path.GetExtension(fileName);
|
||||
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
|
||||
var filePath = Path.Combine(_storagePath, uniqueFileName);
|
||||
|
||||
_logger.LogDebug("Uploading file {FileName} to {FilePath}", fileName, filePath);
|
||||
|
||||
await using var fileStreamOut = new FileStream(filePath, FileMode.Create, FileAccess.Write);
|
||||
await fileStream.CopyToAsync(fileStreamOut);
|
||||
|
||||
// Return a file:// URL for local storage
|
||||
var fileUrl = $"file://{filePath}";
|
||||
|
||||
_logger.LogInformation("Successfully uploaded file {FileName} to {FileUrl}", fileName, fileUrl);
|
||||
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
public async Task<Stream> DownloadAsync(string blobUrl)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
|
||||
|
||||
var filePath = ExtractFilePathFromUrl(blobUrl);
|
||||
|
||||
_logger.LogDebug("Downloading file from {FilePath}", filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"File not found: {filePath}");
|
||||
}
|
||||
|
||||
var memoryStream = new MemoryStream();
|
||||
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||
await fileStream.CopyToAsync(memoryStream);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
_logger.LogDebug("Successfully downloaded file from {FilePath}", filePath);
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string blobUrl)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
|
||||
|
||||
var filePath = ExtractFilePathFromUrl(blobUrl);
|
||||
|
||||
_logger.LogDebug("Deleting file {FilePath}", filePath);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
_logger.LogInformation("Successfully deleted file {FilePath}", filePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("File {FilePath} did not exist when attempting to delete", filePath);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string ExtractFilePathFromUrl(string fileUrl)
|
||||
{
|
||||
string filePath;
|
||||
|
||||
if (fileUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filePath = fileUrl[7..];
|
||||
}
|
||||
else
|
||||
{
|
||||
filePath = fileUrl;
|
||||
}
|
||||
|
||||
// Resolve to absolute path and validate it's within storage directory
|
||||
var fullPath = Path.GetFullPath(filePath);
|
||||
var storagePath = Path.GetFullPath(_storagePath);
|
||||
|
||||
if (!fullPath.StartsWith(storagePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new UnauthorizedAccessException($"Access denied: path is outside storage directory");
|
||||
}
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user