feat(cv): qualify templates and reliable local import processing

This commit is contained in:
cesnimda
2026-10-28 14:36:12 +01:00
parent 73ea888375
commit 3f4db8ca81
69 changed files with 2597 additions and 180 deletions
@@ -79,9 +79,13 @@ public sealed class ApplicationAssetsController : ControllerBase
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
if (request.DocumentSettings is { SchemaVersion: not 1 }) return BadRequest("Unsupported letter settings schema.");
var result = await _assets.SaveCoverLetterAsync(
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct, request?.DocumentSettings);
return result is null ? NotFound() : Ok(result);
try
{
var result = await _assets.SaveCoverLetterAsync(
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct, request?.DocumentSettings);
return result is null ? NotFound() : Ok(result);
}
catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); }
}
[HttpPost("cover-letter/preview")]
@@ -90,8 +94,12 @@ public sealed class ApplicationAssetsController : ControllerBase
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
if (request.DocumentSettings is { SchemaVersion: not 1 }) return BadRequest("Unsupported letter settings schema.");
var result = await _assets.PreviewCoverLetterAsync(userId, jobId, request.Text, request.DocumentSettings, ct);
return result is null ? NotFound() : Ok(result);
try
{
var result = await _assets.PreviewCoverLetterAsync(userId, jobId, request.Text, request.DocumentSettings, ct);
return result is null ? NotFound() : Ok(result);
}
catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); }
}
[HttpPost("cover-letter/export-pdf")]
@@ -118,8 +126,12 @@ public sealed class ApplicationAssetsController : ControllerBase
{
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, request?.ExpectedVersion, ct);
return result is null ? NotFound() : Ok(result);
try
{
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, request?.ExpectedVersion, ct);
return result is null ? NotFound() : Ok(result);
}
catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); }
}
[HttpGet("submitted-packages")]
+44 -11
View File
@@ -16,6 +16,7 @@ namespace JobTrackerApi.Controllers;
[Route("api/auth")]
public sealed class AuthController : ControllerBase
{
private static readonly SemaphoreSlim AvatarProcessingSlots = new(2, 2);
private readonly IConfiguration _cfg;
private readonly UserManager<ApplicationUser> _users;
private readonly ITokenService _tokens;
@@ -933,9 +934,16 @@ public sealed class AuthController : ControllerBase
}
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
var bytes = memory.ToArray();
var bytes = new byte[MaxAvatarBytes + 1];
var length = 0;
while (length < bytes.Length)
{
var read = await stream.ReadAsync(bytes.AsMemory(length), HttpContext.RequestAborted);
if (read == 0) break;
length += read;
}
if (length > MaxAvatarBytes) return BadRequest("Avatar image is too large.");
Array.Resize(ref bytes, length);
var detectedContentType = DetectAvatarContentType(bytes);
if (detectedContentType is null)
@@ -943,15 +951,40 @@ public sealed class AuthController : ControllerBase
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
}
user.AvatarImageDataUrl = await AvatarStorage.StoreAsync(_avatarDataRoot, user.Id, bytes, detectedContentType, HttpContext.RequestAborted);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
if (!await AvatarProcessingSlots.WaitAsync(0, HttpContext.RequestAborted))
return StatusCode(503, new { code = "avatar_busy", message = "Image processing is busy. Try again shortly." });
try
{
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
byte[]? normalized;
try { normalized = AvatarImageProcessor.Normalize(bytes); }
catch (Exception exception) when (exception is DllNotFoundException or TypeInitializationException or EntryPointNotFoundException or BadImageFormatException)
{
return StatusCode(503, new { code = "avatar_decoder_unavailable", message = "Image processing is unavailable. Your previous photo has been preserved." });
}
if (normalized is null) return BadRequest("Select a valid, still PNG, JPEG or WebP image up to 2048 pixels per side and 4 megapixels. Crop or resize larger images.");
var previous = user.AvatarImageDataUrl;
var staged = await AvatarStorage.StageAsync(_avatarDataRoot, user.Id, normalized, HttpContext.RequestAborted);
try
{
user.AvatarImageDataUrl = staged;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
user.AvatarImageDataUrl = previous;
AvatarStorage.TryDelete(staged);
return BadRequest("The photo could not be saved. Your previous photo has been preserved.");
}
}
catch
{
user.AvatarImageDataUrl = previous;
// An acknowledgement failure may follow a committed identity write; retention checks references.
throw;
}
AvatarStorage.TryDelete(previous);
return Ok(new { avatarImageDataUrl = $"data:image/png;base64,{Convert.ToBase64String(normalized)}" });
}
return Ok(new { avatarImageDataUrl = AvatarStorage.Resolve(user.AvatarImageDataUrl) });
finally { AvatarProcessingSlots.Release(); }
}
[HttpDelete("avatar")]
@@ -972,7 +1005,7 @@ public sealed class AuthController : ControllerBase
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
AvatarStorage.Delete(storedAvatar);
AvatarStorage.TryDelete(storedAvatar);
return NoContent();
}
@@ -50,7 +50,7 @@ public sealed class CvVariantController : ControllerBase
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var proThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes;
var themes = CvThemeCatalog.Themes.Select(t => new
var themes = CvThemeCatalog.RevisedThemes.Select(t => new
{
id = t.Id,
name = t.Name,
@@ -67,6 +67,7 @@ public sealed class CvVariantController : ControllerBase
supportedSettings = t.SupportedSettings,
pageMarginMm = t.PageMarginMm,
bodyFont = t.BodyFont,
headingFont = t.HeadingFont,
baseFontSizePt = t.BodySizePt,
lineHeight = t.LineHeight,
});
@@ -82,7 +83,7 @@ public sealed class CvVariantController : ControllerBase
if (user is null) return Unauthorized();
if (!CvThemeCatalog.Exists(themeId)) return NotFound();
var theme = CvThemeCatalog.Resolve(themeId);
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, ShowIcons = true });
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, DesignVersion = 2, ShowIcons = true });
var render = _renderer.Render(TemplatePreviewModel(), theme, settings);
return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName));
}
+2
View File
@@ -28,6 +28,8 @@
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="SkiaSharp" Version="4.153.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.153.1" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
<PackageReference Include="Stripe.net" Version="52.2.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JobTrackerApi.Models;
@@ -6,6 +7,10 @@ namespace JobTrackerApi.Models;
public sealed class CoverLetterDocumentSettings
{
public int SchemaVersion { get; set; } = 1;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? DesignVersion { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? HeadingFont { get; set; }
public string Language { get; set; } = "en";
public string SenderName { get; set; } = "";
public string SenderTitle { get; set; } = "";
@@ -38,16 +43,18 @@ public static class CoverLetterDocumentSettingsJson
public static CoverLetterDocumentSettings Normalize(CoverLetterDocumentSettings? value)
{
value ??= new();
var theme = CvThemeCatalog.Resolve(value.ThemeId);
var theme = CvThemeCatalog.Resolve(value.ThemeId, value.DesignVersion);
var style = CvVariantSettingsJson.Normalize(new()
{
AccentColor = value.AccentColor, BodyFont = value.BodyFont,
AccentColor = value.AccentColor, BodyFont = value.BodyFont, HeadingFont = value.HeadingFont,
BaseFontSizePt = value.BaseFontSizePt, LineHeight = value.LineHeight, PageMarginMm = value.PageMarginMm,
});
static string Text(string? text, int limit) => (text ?? "")[..Math.Min(text?.Length ?? 0, limit)];
return new()
{
SchemaVersion = 1, ThemeId = theme.Id,
DesignVersion = value.DesignVersion is 1 or 2 ? value.DesignVersion : null,
HeadingFont = value.DesignVersion == 2 ? style.HeadingFont ?? theme.HeadingFont : null,
Language = value.Language?.Trim().ToLowerInvariant() is "nb" or "nb-no" ? "nb-NO" : "en",
SenderName = Text(value.SenderName, 300), SenderTitle = Text(value.SenderTitle, 300),
SenderEmail = Text(value.SenderEmail, 300), SenderPhone = Text(value.SenderPhone, 100), SenderLocation = Text(value.SenderLocation, 300),
+41
View File
@@ -38,6 +38,8 @@ public sealed class CvTheme
public double PageMarginMm { get; init; } = 16;
public double SectionGapMm { get; init; } = 6;
public double EntryGapMm { get; init; } = 4.5;
public double NameGapMm { get; init; }
public double HeaderContentGapMm { get; init; }
// plain | band | centered | kicker
public string HeaderStyle { get; init; } = "plain";
@@ -159,6 +161,45 @@ public static class CvThemeCatalog
},
};
// Versioned data keeps existing saved lenses and frozen output on their original design.
public static readonly IReadOnlyList<CvTheme> RevisedThemes = Themes.Select(Revised).ToArray();
private static CvTheme Revised(CvTheme source)
{
var (nameSize, sectionGap, entryGap) = source.Id switch
{
"modern" => (25d, 5.8, 4.0),
"minimal" => (25d, 7.5, 4.8),
"executive" => (28d, 6.5, 4.6),
"technical" => (24d, 4.8, 3.2),
"ats-classic" => (23d, 5.0, 3.4),
"nordic" => (23d, 6.0, 4.2),
"elegant" => (27d, 7.5, 5.0),
"creative" => (20d, 5.5, 3.8),
_ => (25.5, 5.2, 3.6),
};
return new CvTheme
{
Id = source.Id, Name = source.Name, Category = source.Category, Description = source.Description,
Layout = source.Layout, SidebarWidthMm = 68,
Accent = source.Accent, Ink = source.Ink, Muted = source.Muted, Line = source.Line,
Paper = source.Paper, SidebarBg = source.SidebarBg, SidebarInk = source.SidebarInk, HeadingColor = source.HeadingColor,
HeadingFont = source.HeadingFont, BodyFont = source.BodyFont, UtilityFont = source.UtilityFont,
NameSizePt = nameSize, HeadingSizePt = source.HeadingSizePt, BodySizePt = source.BodySizePt, LineHeight = source.LineHeight,
PageMarginMm = source.PageMarginMm, SectionGapMm = sectionGap, EntryGapMm = entryGap,
NameGapMm = source.Id is "modern" or "ats-classic" or "code" ? 3 : 0,
HeaderContentGapMm = source.Id is "modern" or "ats-classic" or "code" ? 5 : 0,
HeaderStyle = source.HeaderStyle, HeadingStyle = source.HeadingStyle, PhotoShape = source.PhotoShape,
DefaultIcons = source.DefaultIcons, AtsFriendly = source.AtsFriendly, Premium = source.Premium,
NumberSections = source.NumberSections, DefaultSkillsStyle = source.DefaultSkillsStyle,
SupportedSettings = new(source.SupportedSettings), SidebarSections = new(source.SidebarSections),
};
}
public static CvTheme Resolve(string? id, int? designVersion) => designVersion == 2
? RevisedThemes.FirstOrDefault(theme => theme.Id == (id ?? string.Empty).Trim().ToLowerInvariant()) ?? RevisedThemes[0]
: Resolve(id);
public static CvTheme Resolve(string? id)
{
var key = (id ?? string.Empty).Trim().ToLowerInvariant();
@@ -9,6 +9,8 @@ namespace JobTrackerApi.Models;
public sealed class CvVariantSettings
{
public string ThemeId { get; set; } = "modern";
// Missing or version 1 retains the original template output.
public int? DesignVersion { get; set; }
// Theme overrides (null => use the theme's own value).
public string? AccentColor { get; set; }
@@ -132,6 +134,7 @@ public static class CvVariantSettingsJson
{
s ??= new CvVariantSettings();
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
s.DesignVersion = s.DesignVersion is 1 or 2 ? s.DesignVersion : null;
s.AccentColor = NormalizeColor(s.AccentColor);
s.TextColor = NormalizeColor(s.TextColor);
s.MutedColor = NormalizeColor(s.MutedColor);
+1 -1
View File
@@ -187,7 +187,7 @@ builder.Services.AddHttpClient("ai-service", client =>
?? builder.Configuration["Summarizer:BaseUrl"]
?? "http://127.0.0.1:8001";
client.BaseAddress = new Uri(baseUrl);
client.Timeout = TimeSpan.FromSeconds(30);
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(builder.Configuration.GetValue("Ai:RequestTimeoutSeconds", 30), 5, 300));
var serviceToken = builder.Configuration["Ai:ServiceToken"];
if (!string.IsNullOrWhiteSpace(serviceToken))
@@ -48,6 +48,11 @@ public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths pat
var avatar = await db.Users.AsNoTracking().Where(item => item.Id == ownerUserId).Select(item => item.AvatarImageDataUrl).FirstOrDefaultAsync(cancellationToken);
AddAvatar(files, warnings, ownerUserId, avatar);
foreach (var portrait in AvatarStorage.PortraitFiles(paths.DataRoot, AppPaths.GetOwnerStorageKey(ownerUserId)))
{
if (files.Any(file => string.Equals(file.SourcePath, Path.GetFullPath(portrait), OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))) continue;
AddPath(files, warnings, portrait, $"files/avatar/{SafeSegment(Path.GetFileName(portrait))}", "avatar", candidate => IsManagedPath(paths.DataRoot, candidate));
}
AddDirectory(files, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv");
AddDirectory(files, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export");
if (includeAccountExports)
+7 -6
View File
@@ -155,13 +155,13 @@ public sealed class AiOperationWorker(
private readonly IReadOnlyDictionary<string, IAiOperationHandler> _handlers = registeredHandlers
.ToDictionary(handler => handler.TaskType, StringComparer.Ordinal);
public async Task<bool> RunOnceAsync(CancellationToken stoppingToken)
public async Task<bool> RunOnceAsync(CancellationToken stoppingToken, bool documentImportsOnly = false)
{
if (_handlers.Count == 0) return false;
var leaseSeconds = Math.Clamp(configuration.GetValue("AiQueue:LeaseSeconds", 120), 30, 1800);
await using var claimScope = scopes.CreateAsyncScope();
var lease = await claimScope.ServiceProvider.GetRequiredService<UserOperationStore>()
.ClaimNextAsync(TimeSpan.FromSeconds(leaseSeconds), stoppingToken, _handlers.Keys.ToArray());
.ClaimNextAsync(TimeSpan.FromSeconds(leaseSeconds), stoppingToken, _handlers.Keys.ToArray(), documentImportsOnly);
if (lease is null) return false;
await using var ownerScope = scopes.CreateAsyncScope();
@@ -255,17 +255,18 @@ public sealed class AiOperationHostedService(AiOperationWorker worker, IConfigur
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!configuration.GetValue("Workers:AiOperationsEnabled", false)) return;
var aiEnabled = configuration.GetValue("Workers:AiOperationsEnabled", false);
if (!aiEnabled && !configuration.GetValue("Workers:CvDocumentImportsEnabled", false)) return;
var concurrency = Math.Clamp(configuration.GetValue("AiQueue:WorkerConcurrency", 1), 1, 4);
await Task.WhenAll(Enumerable.Range(0, concurrency).Select(_ => RunWorkerAsync(stoppingToken)));
await Task.WhenAll(Enumerable.Range(0, concurrency).Select(_ => RunWorkerAsync(stoppingToken, !aiEnabled)));
}
private async Task RunWorkerAsync(CancellationToken stoppingToken)
private async Task RunWorkerAsync(CancellationToken stoppingToken, bool documentImportsOnly)
{
var idleDelayMs = Math.Clamp(configuration.GetValue("AiQueue:IdleDelayMs", 1000), 100, 10_000);
while (!stoppingToken.IsCancellationRequested)
{
if (!await worker.RunOnceAsync(stoppingToken))
if (!await worker.RunOnceAsync(stoppingToken, documentImportsOnly))
await Task.Delay(idleDelayMs, stoppingToken);
}
}
@@ -45,6 +45,8 @@ public sealed record CoverLetterDto(
int Revision = 1,
CoverLetterDocumentSettings? DocumentSettings = null);
public sealed class CoverLetterThemeAccessException() : Exception("This theme requires Pro.");
public interface IApplicationAssetsService
{
Task<ApplicationCvDto?> GetCvAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
@@ -293,7 +295,9 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
public async Task<CoverLetterRenderDto?> PreviewCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, CoverLetterDocumentSettings? settings, CancellationToken ct)
{
var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct);
return job is null ? null : _letters.Render(text, settings ?? CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
if (job is null) return null;
await RequireLetterThemeAsync(_db, job, settings, ct);
return _letters.Render(text, settings ?? CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
}
public async Task<CoverLetterRenderDto?> RenderSavedCoverLetterAsync(string ownerUserId, int jobApplicationId, int? expectedVersion, CancellationToken ct)
@@ -331,6 +335,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
internal static async Task StageCoverLetterAsync(JobTrackerContext db, JobApplication job, string? text,
string source, string? aiAction, CancellationToken ct, CoverLetterDocumentSettings? settings = null)
{
await RequireLetterThemeAsync(db, job, settings, ct);
var currentSettings = CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
var nextSettings = settings is null ? currentSettings : CoverLetterDocumentSettingsJson.Serialize(settings);
if (string.Equals(job.CoverLetterText, text, StringComparison.Ordinal) && nextSettings == currentSettings) return;
@@ -362,6 +367,21 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
if (settings is not null) job.CoverLetterDocumentSettingsJson = nextSettings;
}
private static async Task RequireLetterThemeAsync(JobTrackerContext db, JobApplication job, CoverLetterDocumentSettings? settings, CancellationToken ct)
{
if (settings is null) return;
var next = CoverLetterDocumentSettingsJson.Normalize(settings);
if (next.DesignVersion != 2 || CvThemeCatalog.CanUse(next.ThemeId, false)) return;
var current = CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson);
if (current.DesignVersion == 2 && current.ThemeId == next.ThemeId) return;
var roles = await (from userRole in db.UserRoles.AsNoTracking()
join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
where userRole.UserId == job.OwnerUserId && role.Name != null
select role.Name!).ToListAsync(ct);
if (!CvThemeCatalog.CanUse(next.ThemeId, AccountPlans.ForRoles(roles).ProThemes))
throw new CoverLetterThemeAccessException();
}
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
_db.JobApplications.AsNoTracking()
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
@@ -0,0 +1,38 @@
using SkiaSharp;
namespace JobTrackerApi.Services;
public static class AvatarImageProcessor
{
public static byte[]? Normalize(byte[] source)
{
if (source.Length is 0 or > 1_000_000) return null;
using var data = SKData.CreateCopy(source);
using var codec = SKCodec.Create(data);
if (codec is null || codec.EncodedFormat is not (SKEncodedImageFormat.Png or SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Webp)) return null;
var info = codec.Info;
if (info.Width is < 1 or > 2048 || info.Height is < 1 or > 2048 || (long)info.Width * info.Height > 4_000_000 || codec.FrameCount > 1) return null;
using var pixels = new SKBitmap(info.Width, info.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
if (codec.GetPixels(pixels.Info, pixels.GetPixels()) != SKCodecResult.Success) return null;
var rotated = codec.EncodedOrigin is SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightTop or SKEncodedOrigin.RightBottom or SKEncodedOrigin.LeftBottom;
using var oriented = new SKBitmap(rotated ? info.Height : info.Width, rotated ? info.Width : info.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
using (var canvas = new SKCanvas(oriented))
{
switch (codec.EncodedOrigin)
{
case SKEncodedOrigin.TopRight: canvas.Translate(info.Width, 0); canvas.Scale(-1, 1); break;
case SKEncodedOrigin.BottomRight: canvas.Translate(info.Width, info.Height); canvas.RotateDegrees(180); break;
case SKEncodedOrigin.BottomLeft: canvas.Translate(0, info.Height); canvas.Scale(1, -1); break;
case SKEncodedOrigin.LeftTop: canvas.RotateDegrees(90); canvas.Scale(1, -1); break;
case SKEncodedOrigin.RightTop: canvas.Translate(info.Height, 0); canvas.RotateDegrees(90); break;
case SKEncodedOrigin.RightBottom: canvas.Translate(info.Height, info.Width); canvas.RotateDegrees(90); canvas.Scale(-1, 1); break;
case SKEncodedOrigin.LeftBottom: canvas.Translate(0, info.Width); canvas.RotateDegrees(270); break;
}
canvas.DrawBitmap(pixels, 0, 0, new SKSamplingOptions(SKFilterMode.Nearest), null);
}
// Re-encoding decoded pixels drops source metadata and trailing payloads.
using var image = SKImage.FromBitmap(oriented);
using var encoded = image.Encode(SKEncodedImageFormat.Png, 100);
return encoded is null || encoded.Size > 1_000_000 ? null : encoded.ToArray();
}
}
+60
View File
@@ -1,5 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
@@ -7,6 +9,64 @@ public static class AvatarStorage
{
private const string FilePrefix = "file:";
public static async Task<string> StageAsync(string dataRoot, string userId, byte[] bytes, CancellationToken cancellationToken)
{
var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant();
var folder = Path.Combine(dataRoot, "Avatars", key);
Directory.CreateDirectory(folder);
var path = Path.Combine(folder, $"avatar-{Guid.NewGuid():N}.png");
try { await File.WriteAllBytesAsync(path, bytes, cancellationToken); }
catch
{
TryDelete(FilePrefix + path);
throw;
}
return FilePrefix + path;
}
public static IEnumerable<string> PortraitFiles(string dataRoot, string ownerKey)
{
var root = Path.Combine(dataRoot, "Avatars");
var folder = Path.Combine(root, ownerKey);
if (ownerKey.Length != 64 || !ownerKey.All(Uri.IsHexDigit)
|| !Directory.Exists(folder) || (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0
|| (File.GetAttributes(folder) & FileAttributes.ReparsePoint) != 0) yield break;
foreach (var path in Directory.EnumerateFiles(folder, "avatar*", new EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true }))
{
var name = Path.GetFileName(path);
if (name is "avatar.png" or "avatar.jpg" or "avatar.webp"
|| (name.StartsWith("avatar-", StringComparison.Ordinal) && name.EndsWith(".png", StringComparison.Ordinal)
&& name.Length == 43 && Guid.TryParseExact(name[7..^4], "N", out _))) yield return path;
}
}
public static async Task<int> SweepOrphansAsync(JobTrackerContext db, string dataRoot, DateTimeOffset now, CancellationToken token)
{
var root = Path.Combine(dataRoot, "Avatars");
if (!Directory.Exists(root) || (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) return 0;
var candidates = Directory.EnumerateDirectories(root, "*", new EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true })
.Take(1000).SelectMany(folder => PortraitFiles(dataRoot, Path.GetFileName(folder)).Take(1000))
.Where(path => File.GetLastWriteTimeUtc(path) <= now.AddDays(-1).UtcDateTime)
.Take(1000).Select(path => FilePrefix + Path.GetFullPath(path)).ToList();
var referenced = (await db.Users.AsNoTracking().Where(user => candidates.Contains(user.AvatarImageDataUrl!))
.Select(user => user.AvatarImageDataUrl!).ToListAsync(token))
.ToHashSet(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
var removed = 0;
foreach (var candidate in candidates)
{
token.ThrowIfCancellationRequested();
if (!referenced.Contains(candidate) && TryDelete(candidate)) removed++;
}
return removed;
}
public static bool TryDelete(string? value)
{
try { Delete(value); return true; }
catch (IOException) { return false; }
catch (UnauthorizedAccessException) { return false; }
}
public static async Task<string> StoreAsync(string dataRoot, string userId, byte[] bytes, string contentType, CancellationToken cancellationToken)
{
var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant();
@@ -15,6 +15,8 @@ public sealed class CoverLetterDocumentRenderer
static string E(string? value) => WebUtility.HtmlEncode(value ?? "");
static string F(double? value) => value!.Value.ToString("0.##", CultureInfo.InvariantCulture);
var font = s.BodyFont!;
var theme = CvThemeCatalog.Resolve(s.ThemeId, s.DesignVersion);
var fonts = s.DesignVersion == 2 ? CvDocumentFonts.Embed(font, s.HeadingFont!, theme.UtilityFont) : CvDocumentFonts.Embed(font);
var size = s.PageSize == "letter" ? "215.9mm 279.4mm" : "210mm 297mm";
var width = s.PageSize == "letter" ? "215.9mm" : "210mm";
var height = s.PageSize == "letter" ? "279.4mm" : "297mm";
@@ -40,7 +42,7 @@ public sealed class CoverLetterDocumentRenderer
<!DOCTYPE html><html lang="{{E(s.Language)}}"><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; font-src data:; base-uri 'none'">
<title>{{E(string.IsNullOrEmpty(title) ? letterLabel : title)}}</title><style>
{{CvDocumentFonts.Embed(font)}}
{{fonts}}
:root{--cv-page-margin:{{F(s.PageMarginMm)}}mm}
*{box-sizing:border-box}html,body{margin:0;padding:0;color:#111827;background:#fff}
body{font-family:{{CvDocumentFonts.Stack(font)}};font-size:{{F(s.BaseFontSizePt)}}pt;line-height:{{F(s.LineHeight)}}}
@@ -56,6 +58,35 @@ public sealed class CoverLetterDocumentRenderer
<div class="date">{{E(s.Date)}}</div><div class="recipient">{{E(s.Recipient)}}</div>{{subjectHeading}}
<main aria-label="{{E(bodyLabel)}}">{{body}}</main></article></body></html>
""";
if (s.DesignVersion == 2)
{
var css = $"h1,h2{{font-family:{CvDocumentFonts.Stack(s.HeadingFont!)};}}h1{{font-size:{F(theme.NameSizePt - 2)}pt;}}header{{border-bottom-width:.4mm;}}";
css += theme.HeaderStyle switch
{
"band" when theme.Layout == "header-band" => $"header{{background:{s.AccentColor};color:{ThemedCvRenderer.ContrastInk(s.AccentColor!)};padding:6mm;margin-bottom:8mm;border:0;}}header h1{{color:inherit;}}",
"centered" => "header{text-align:center;padding-bottom:7mm;margin-bottom:9mm;}h1{font-weight:600;}",
"kicker" => $"header::before{{content:'{letterLabel}';display:block;letter-spacing:.2em;text-transform:uppercase;color:{s.AccentColor};font-size:8pt;margin-bottom:3mm;}}",
_ => "header{padding-bottom:6mm;margin-bottom:8mm;}",
};
if (theme.HeadingStyle == "plain" && theme.HeaderStyle == "plain") css += "header{border:0;}";
if (theme.NumberSections) css += $".contact,.date{{font-family:{CvDocumentFonts.Stack(theme.UtilityFont)};font-size:9pt;}}";
if (theme.Layout is "sidebar-left" or "sidebar-right")
{
var right = theme.Layout == "sidebar-right";
var edge = right ? "right" : "left";
var headerColumn = right ? 2 : 1;
var contentColumn = right ? 1 : 2;
var columns = right ? $"minmax(0,1fr) {F(theme.SidebarWidthMm)}mm" : $"{F(theme.SidebarWidthMm)}mm minmax(0,1fr)";
css += $".letter-paper{{display:grid;grid-template-columns:{columns};grid-template-rows:auto auto auto auto;align-content:start;padding:0;background:transparent;position:relative;z-index:1;}}" +
$".letter-paper>header{{grid-column:{headerColumn};grid-row:1 / span 4;padding:8mm;color:{theme.SidebarInk};border:0;margin:0;}}header h1{{color:inherit;}}" +
$".letter-paper>.date,.letter-paper>.recipient,.letter-paper>h2,.letter-paper>main{{grid-column:{contentColumn};padding-left:{F(s.PageMarginMm)}mm;padding-right:{F(s.PageMarginMm)}mm;background:white;min-width:0;}}" +
$"body::before{{content:'';position:fixed;{edge}:0;top:0;bottom:0;width:{F(theme.SidebarWidthMm)}mm;background:{theme.SidebarBg};}}" +
$"@page{{@top-{edge}{{content:'';background:{theme.SidebarBg};width:{F(theme.SidebarWidthMm)}mm;}}@bottom-{edge}{{content:'';background:{theme.SidebarBg};width:{F(theme.SidebarWidthMm)}mm;}}}}" +
"@media print{.letter-paper{padding:0;}}";
}
html = html.Replace("</style>", css + "</style>", StringComparison.Ordinal)
.Replace("class=\"page letter-paper\"", $"class=\"page letter-paper letter-theme-{theme.Id}\"", StringComparison.Ordinal);
}
return new(html, s.ThemeId, "cover-letter.pdf");
}
}
@@ -90,6 +90,6 @@ public sealed class CvImportRetentionHostedService(IServiceScopeFactory scopes,
catch (UnauthorizedAccessException) { }
}
}
return removed;
return removed + await AvatarStorage.SweepOrphansAsync(db, paths.DataRoot, now, token);
}
}
+9 -2
View File
@@ -23,19 +23,26 @@ public interface ICvProcessingQueue
/// Compatibility name for the CV producer bridge. Durable scheduling and execution are owned by
/// the shared AI operation queue; this type does not keep an in-memory CV queue.
/// </summary>
public sealed class CvProcessingQueue(AiOperationAdmission admission) : ICvProcessingQueue
public sealed class CvProcessingQueue(AiOperationAdmission admission, JobTrackerContext db, IConfiguration configuration) : ICvProcessingQueue
{
public const string TaskType = "cv.process";
public const string SubjectType = "cv_extraction_run";
public async Task<AiOperationAdmissionResult?> EnqueueAsync(int runId, CancellationToken cancellationToken = default)
=> await admission.EnqueueAsync(
{
var document = await db.CvExtractionRuns.AnyAsync(run => run.Id == runId &&
run.ParserVersion == ProfileCvController.DocumentParserVersion && run.Trigger == "document_import", cancellationToken);
if (document && !configuration.GetValue("Workers:AiOperationsEnabled", false) &&
!configuration.GetValue("Workers:CvDocumentImportsEnabled", false))
throw new AiOperationAdmissionException("cv_worker_disabled", "CV processing is disabled on this server. Ask the administrator to enable document processing, then retry the upload.", StatusCodes.Status503ServiceUnavailable);
return await admission.EnqueueAsync(
TaskType,
$"run:{runId}",
SubjectType,
runId.ToString(System.Globalization.CultureInfo.InvariantCulture),
AiOperationPriorities.UserVisible,
cancellationToken);
}
}
public sealed class NoOpCvProcessingQueue : ICvProcessingQueue
+27 -9
View File
@@ -24,6 +24,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
public ThemedCvRenderResult Render(CvRenderModel model, CvTheme theme, CvVariantSettings settings)
{
settings = CvVariantSettingsJson.Normalize(settings);
if (settings.DesignVersion == 2) theme = CvThemeCatalog.Resolve(theme.Id, settings.DesignVersion);
var accent = Override(settings.AccentColor, theme.Accent);
var headerInk = ContrastInk(accent);
var ink = Override(settings.TextColor, theme.Ink);
@@ -238,6 +239,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
var headingSize = settings.HeadingSizePt ?? t.HeadingSizePt;
var lineHeight = settings.LineHeight ?? t.LineHeight;
var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm;
var sidebarPadding = settings.DesignVersion == 2 ? "8" : margin;
var sidebarBackground = settings.DesignVersion == 2 ? "var(--cv-sidebar-bg)" : "var(--cv-accent-color)";
var sidebarInk = settings.DesignVersion == 2 ? "var(--cv-sidebar-ink)" : "var(--cv-accent-ink)";
var sidebarVariables = settings.DesignVersion == 2 ? $"--cv-sidebar-bg:{t.SidebarBg};--cv-sidebar-ink:{t.SidebarInk};" : string.Empty;
var sidebarDetailCss = settings.DesignVersion == 2
? ".sidebar .bullets li::marker,.sidebar .skill-group-name{color:var(--cv-sidebar-ink);}" : string.Empty;
var headingCss = headingStyle switch
{
"underline" => ".section-title{border-bottom:1.5px solid var(--cv-accent-soft);padding-bottom:1.5mm;}",
@@ -250,13 +257,13 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
: $"{F(sidebarWidth)}mm minmax(0,1fr)";
var layoutCss = twoColumn
? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}}
.sidebar{{background:var(--cv-accent-color);color:var(--cv-accent-ink);padding:{margin}mm;}}
.sidebar .section-title{{color:var(--cv-accent-ink);border-color:color-mix(in srgb,var(--cv-accent-ink) 38%,transparent);}}
.sidebar .tag{{border-color:color-mix(in srgb,var(--cv-accent-ink) 45%,transparent);}}
.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:var(--cv-accent-ink);}}
.sidebar{{background:{sidebarBackground};color:{sidebarInk};padding:{sidebarPadding}mm;}}
.sidebar .section-title{{color:{sidebarInk};border-color:color-mix(in srgb,{sidebarInk} 38%,transparent);}}
.sidebar .tag{{border-color:color-mix(in srgb,{sidebarInk} 45%,transparent);}}
.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{sidebarInk};}}
.sidebar a{{color:inherit;}}
.main{{padding:{margin}mm;}}
.hero .name{{color:var(--cv-accent-ink);}}"
.hero .name{{color:{sidebarInk};}}"
: $@".main{{padding:0 {margin}mm {margin}mm {margin}mm;}}
.header{{padding:{margin}mm {margin}mm {F(t.SectionGapMm * density)}mm {margin}mm;display:flex;gap:6mm;align-items:center;}}
.header-band{{background:{accent};color:{headerInk};}}
@@ -269,9 +276,20 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
? $@".main{{counter-reset:cv-section;}} .main>.section>.section-title{{counter-increment:cv-section;font-family:{CvDocumentFonts.Stack(t.UtilityFont)};font-weight:600;}} .main>.section>.section-title::before{{content:counter(cv-section,decimal-leading-zero) ' — ';color:var(--cv-accent-color);}}"
: string.Empty;
var revisedCss = settings.DesignVersion == 2
? $".header .name{{margin-bottom:{F(t.NameGapMm * density)}mm;}}.header + .main{{padding-top:{F(t.HeaderContentGapMm * density)}mm;}}" : string.Empty;
if (settings.DesignVersion == 2 && twoColumn)
{
var edge = layout == "sidebar-right" ? "right" : "left";
// Margin boxes paint the sidebar bleed without changing the repeated text safe area.
revisedCss += $"@page{{@top-{edge}{{content:\"\";background:{t.SidebarBg};width:{F(sidebarWidth)}mm;}}@bottom-{edge}{{content:\"\";background:{t.SidebarBg};width:{F(sidebarWidth)}mm;}}}}" +
$"body::before{{content:\"\";position:fixed;{edge}:0;top:0;bottom:0;width:{F(sidebarWidth)}mm;background:{t.SidebarBg};z-index:0;}}" +
".page{position:relative;background:transparent;}.cols{position:relative;z-index:1;}.main{background:var(--cv-paper);}.sidebar{background:transparent;}";
}
return $@"
*{{box-sizing:border-box;}}
:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};--cv-page-margin:{margin}mm;}}
:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};--cv-page-margin:{margin}mm;{sidebarVariables}}}
html,body{{min-width:0;}}
body{{margin:0;background:#e9edf2;color:var(--cv-ink);font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:var(--cv-paper);overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
@@ -325,7 +343,7 @@ h1,h2{{font-family:{headingFont};}}
.entry-meta{{color:{muted};font-size:{F(bodySize - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}}
.entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}}
.entry-tags{{margin-top:1.4mm;}}
{layoutCss}
{layoutCss}{sidebarDetailCss}
{numberedSections}
/* Print quality: keep normal entries whole, but allow intentionally classified long entries and
long list items to flow. An unsplittable block taller than a page is otherwise clipped. */
@@ -349,7 +367,7 @@ body{{background:transparent;}}
.main,.sidebar{{padding-top:0;padding-bottom:0;}}
}}
@page{{size:{page.w} {page.h};margin:{margin}mm 0;}}
";
" + revisedCss;
}
private static double DensityScale(string? density) => (density ?? "balanced").Trim().ToLowerInvariant() switch
@@ -374,7 +392,7 @@ body{{background:transparent;}}
private static string Override(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
private static string ContrastInk(string background)
internal static string ContrastInk(string background)
{
if (background.Length != 7 || background[0] != '#' || !background.Skip(1).All(Uri.IsHexDigit)) return "#000";
+8 -1
View File
@@ -107,7 +107,8 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
public async Task<UserOperationLease?> ClaimNextAsync(
TimeSpan leaseDuration,
CancellationToken cancellationToken,
IReadOnlyCollection<string>? allowedTaskTypes = null)
IReadOnlyCollection<string>? allowedTaskTypes = null,
bool documentImportsOnly = false)
{
if (db.CurrentUserId is not null) throw new InvalidOperationException("Worker claims require a neutral background scope.");
ValidateLeaseDuration(leaseDuration);
@@ -123,6 +124,12 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
(operation.DeadlineAtUtc == null || operation.DeadlineAtUtc > now));
if (allowedTaskTypes is { Count: > 0 })
candidates = candidates.Where(operation => allowedTaskTypes.Contains(operation.TaskType));
if (documentImportsOnly)
candidates = candidates.Where(operation => operation.TaskType == CvProcessingQueue.TaskType &&
operation.SubjectType == CvProcessingQueue.SubjectType &&
db.CvExtractionRuns.IgnoreQueryFilters().Any(run => run.OwnerUserId == operation.OwnerUserId &&
run.Id.ToString() == operation.SubjectId && run.ParserVersion == Controllers.ProfileCvController.DocumentParserVersion &&
run.Trigger == "document_import"));
var candidateIds = await candidates
.OrderByDescending(operation => operation.Priority)
.ThenBy(operation => operation.CreatedAtUtc)
@@ -1,4 +1,7 @@
{
"Workers": {
"CvDocumentImportsEnabled": true
},
"Database": {
"Provider": "mariadb",
"ServerVersion": "12.2.2"
+2 -1
View File
@@ -20,7 +20,8 @@
"SavedJobSearchAlertsEnabled": true,
"DailyExportEnabled": false,
"JobEnrichmentEnabled": false,
"AiOperationsEnabled": false
"AiOperationsEnabled": false,
"CvDocumentImportsEnabled": false
},
"JobDiscovery": {
"SavedSearchIntervalHours": 6
+26
View File
@@ -112,6 +112,22 @@
"System.Drawing.Common": "6.0.0"
}
},
"SkiaSharp": {
"type": "Direct",
"requested": "[4.153.1, )",
"resolved": "4.153.1",
"contentHash": "TghYLOUaP3q6rTjQw7U9uQaaXh8lEs9rfA1U7/VtmyAptVDtj/SmjtcWzFWmcC/VMWxe3QgbV9uyvqDL7ySFtQ==",
"dependencies": {
"SkiaSharp.NativeAssets.Win32": "4.153.1",
"SkiaSharp.NativeAssets.macOS": "4.153.1"
}
},
"SkiaSharp.NativeAssets.Linux.NoDependencies": {
"type": "Direct",
"requested": "[4.153.1, )",
"resolved": "4.153.1",
"contentHash": "NrnPjcxjcRau4GMOY0BTpUdMey+MnmLU3jC5AuExEFHPS2NkX1tdkXW0n/c5nQMMHBTAMpHnt7qEOpXs2FNfIQ=="
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "Direct",
"requested": "[2.1.12, )",
@@ -473,6 +489,16 @@
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "YTojWeWIhnpF7uC8vpMxB5wRONKkDB2t3r/nNPA3DL2pgj9fqMXRevA/rIanYazWnco4jo1ufgp3HkXuh1Tz8A=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "Yqg6rChTVh0BUE8QQxtOA3uUBnihfgID4yex/AvDa0+os2bVcBjmI3Qj9tnOB+R1hd/Jry1Gwl8qw5JI9HZG8Q=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.10",