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:
2026-01-20 16:45:43 +01:00
parent c6d52a38b2
commit f1ccd217d8
35 changed files with 1791 additions and 415 deletions

View 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;
}
}