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

@@ -1,190 +0,0 @@
@using Microsoft.AspNetCore.Components.Forms
<div class="cv-uploader @(_isDragOver ? "drag-over" : "")"
@ondragenter="HandleDragEnter"
@ondragenter:preventDefault
@ondragleave="HandleDragLeave"
@ondragleave:preventDefault
@ondragover:preventDefault
@ondrop="HandleDrop"
@ondrop:preventDefault>
<InputFile OnChange="HandleFileSelected"
accept=".pdf,.docx"
class="cv-uploader-input"
id="cv-file-input" />
<label for="cv-file-input" class="cv-uploader-label">
@if (string.IsNullOrEmpty(_selectedFileName))
{
<div class="cv-uploader-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-cloud-arrow-up" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M7.646 5.146a.5.5 0 0 1 .708 0l2 2a.5.5 0 0 1-.708.708L8.5 6.707V10.5a.5.5 0 0 1-1 0V6.707L6.354 7.854a.5.5 0 1 1-.708-.708z"/>
<path d="M4.406 3.342A5.53 5.53 0 0 1 8 2c2.69 0 4.923 2 5.166 4.579C14.758 6.804 16 8.137 16 9.773 16 11.569 14.502 13 12.687 13H3.781C1.708 13 0 11.366 0 9.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383m.653.757c-.757.653-1.153 1.44-1.153 2.056v.448l-.445.049C2.064 6.805 1 7.952 1 9.318 1 10.785 2.23 12 3.781 12h8.906C13.98 12 15 10.988 15 9.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 4.825 10.328 3 8 3a4.53 4.53 0 0 0-2.941 1.1z"/>
</svg>
</div>
<div class="cv-uploader-text">
<span class="cv-uploader-title">Drag and drop your CV here</span>
<span class="cv-uploader-subtitle">or click to browse</span>
<span class="cv-uploader-hint">Accepts .pdf and .docx files (max 10MB)</span>
</div>
}
else
{
<div class="cv-uploader-icon text-success">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-file-earmark-check" viewBox="0 0 16 16">
<path d="M10.854 7.854a.5.5 0 0 0-.708-.708L7.5 9.793 6.354 8.646a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0z"/>
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2M9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5z"/>
</svg>
</div>
<div class="cv-uploader-text">
<span class="cv-uploader-title text-success">File selected</span>
<span class="cv-uploader-filename">@_selectedFileName</span>
<span class="cv-uploader-hint">Click or drag to replace</span>
</div>
}
</label>
@if (!string.IsNullOrEmpty(_errorMessage))
{
<div class="alert alert-danger mt-3 mb-0" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle-fill me-2" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
</svg>
@_errorMessage
</div>
}
</div>
<style>
.cv-uploader {
position: relative;
border: 2px dashed #dee2e6;
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
background-color: #f8f9fa;
transition: all 0.2s ease-in-out;
}
.cv-uploader:hover,
.cv-uploader.drag-over {
border-color: #0d6efd;
background-color: #e7f1ff;
}
.cv-uploader.drag-over {
transform: scale(1.02);
}
.cv-uploader-input {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
opacity: 0;
cursor: pointer;
}
.cv-uploader-label {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
cursor: pointer;
margin: 0;
}
.cv-uploader-icon {
color: #6c757d;
}
.cv-uploader.drag-over .cv-uploader-icon {
color: #0d6efd;
}
.cv-uploader-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cv-uploader-title {
font-size: 1.125rem;
font-weight: 500;
color: #212529;
}
.cv-uploader-subtitle {
font-size: 0.875rem;
color: #6c757d;
}
.cv-uploader-hint {
font-size: 0.75rem;
color: #adb5bd;
}
.cv-uploader-filename {
font-size: 0.875rem;
color: #495057;
font-weight: 500;
word-break: break-all;
}
</style>
@code {
private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB
private static readonly string[] AllowedExtensions = [".pdf", ".docx"];
private bool _isDragOver;
private string? _selectedFileName;
private string? _errorMessage;
[Parameter]
public EventCallback<IBrowserFile> OnFileSelected { get; set; }
private void HandleDragEnter()
{
_isDragOver = true;
}
private void HandleDragLeave()
{
_isDragOver = false;
}
private void HandleDrop()
{
_isDragOver = false;
}
private async Task HandleFileSelected(InputFileChangeEventArgs e)
{
_errorMessage = null;
_selectedFileName = null;
var file = e.File;
if (file is null)
{
return;
}
var extension = Path.GetExtension(file.Name).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
{
_errorMessage = "Invalid file type. Please upload a .pdf or .docx file.";
return;
}
if (file.Size > MaxFileSizeBytes)
{
_errorMessage = "File size exceeds 10MB limit. Please upload a smaller file.";
return;
}
_selectedFileName = file.Name;
await OnFileSelected.InvokeAsync(file);
}
}