feat(cv): qualify templates and reliable local import processing
This commit is contained in:
@@ -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")]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user