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