feat/Update_Controllers_to_Allow_for_Premium_Membership

This commit is contained in:
cesnimda
2026-08-03 09:17:28 +02:00
parent de937d25dc
commit c3f4a57195
187 changed files with 26062 additions and 991 deletions
@@ -264,8 +264,7 @@ public sealed class AdminSystemController : ControllerBase
: $"{dbWarning} {statusWarning}";
}
var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim())
&& !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim());
var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim());
EmailSettingsSnapshot emailSettings;
try
{
@@ -0,0 +1,59 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/ai/settings")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class AiSettingsController(
UserManager<ApplicationUser> users,
AiPrivacyPolicy privacyPolicy) : ControllerBase
{
public sealed record AiSettingsRequest(bool Enabled, bool ExternalProcessingAllowed);
public sealed record AiSettingsDto(
bool Enabled,
bool ExternalProcessingAllowed,
bool ExternalProcessingAvailable,
bool EffectiveExternalProcessing,
string Provider);
[HttpGet]
public async Task<ActionResult<AiSettingsDto>> Get(CancellationToken cancellationToken)
{
var user = await users.GetUserAsync(User);
if (user is null) return Unauthorized();
return Ok(await ToDtoAsync(user, cancellationToken));
}
[HttpPut]
public async Task<ActionResult<AiSettingsDto>> Put(
[FromBody] AiSettingsRequest request,
CancellationToken cancellationToken)
{
var user = await users.GetUserAsync(User);
if (user is null) return Unauthorized();
user.AiEnabled = request.Enabled;
user.ExternalAiProcessingAllowed = request.ExternalProcessingAllowed;
var result = await users.UpdateAsync(user);
if (!result.Succeeded)
return Problem("AI privacy settings could not be saved.", statusCode: StatusCodes.Status500InternalServerError);
return Ok(await ToDtoAsync(user, cancellationToken));
}
private async Task<AiSettingsDto> ToDtoAsync(ApplicationUser user, CancellationToken cancellationToken)
{
var decision = await privacyPolicy.EvaluateAsync(user.Id, cancellationToken);
return new AiSettingsDto(
user.AiEnabled,
user.ExternalAiProcessingAllowed,
privacyPolicy.ExternalProcessingAvailable,
decision.ExternalProcessingAllowed,
decision.Provider);
}
}
+17 -3
View File
@@ -33,10 +33,14 @@ public sealed class AiUsageController : ControllerBase
var roles = await _users.GetRolesAsync(user);
var entitlements = AccountPlans.ForRoles(roles);
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
var interactions = _db.AiInteractions.Where(x => x.OwnerUserId == user.Id);
var currentMonth = _db.Database.IsSqlite()
? Sum((await interactions.ToListAsync(cancellationToken)).Where(x => x.CreatedAtUtc >= monthStart))
: await SumAsync(interactions.Where(x => x.CreatedAtUtc >= monthStart), cancellationToken);
return Ok(new UsageDto(
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart), cancellationToken),
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id), cancellationToken),
entitlements.AdvancedAi ? "premium" : "free",
currentMonth,
await SumAsync(interactions, cancellationToken),
AccountPlans.Name(entitlements),
entitlements.MonthlyAiCalls,
entitlements.MonthlyAiTokens,
await _db.Attachments.Where(x => x.JobApplication.OwnerUserId == user.Id).SumAsync(x => (long?)x.FileSize, cancellationToken) ?? 0,
@@ -52,4 +56,14 @@ public sealed class AiUsageController : ControllerBase
group.Sum(x => (long)x.EstimatedTokenCount))).FirstOrDefaultAsync(cancellationToken);
return totals ?? new UsagePeriodDto(0, 0, 0, 0);
}
private static UsagePeriodDto Sum(IEnumerable<AiInteraction> interactions)
{
var rows = interactions.ToList();
return new UsagePeriodDto(
rows.Count,
rows.Sum(x => (long)x.InputCharacterCount),
rows.Sum(x => (long)x.OutputCharacterCount),
rows.Sum(x => (long)x.EstimatedTokenCount));
}
}
@@ -35,6 +35,7 @@ public sealed class AiWorkspaceController : ControllerBase
public ActionResult<object> Modules() => Ok(new { modules = _workspace.Modules, provider = ResolveProvider() });
[HttpPost("generate")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<InteractionDto>> Generate(int jobId, [FromBody] GenerateRequest request, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
@@ -46,14 +47,32 @@ public sealed class AiWorkspaceController : ControllerBase
var roles = await _users.GetRolesAsync(user);
var entitlements = AccountPlans.ForRoles(roles);
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
var used = await _db.AiInteractions
.Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart)
.GroupBy(_ => 1)
.Select(g => new { Calls = g.Count(), Tokens = g.Sum(x => (long)x.EstimatedTokenCount) })
.FirstOrDefaultAsync(ct);
if ((used?.Calls ?? 0) >= entitlements.MonthlyAiCalls)
var interactions = _db.AiInteractions.Where(x => x.OwnerUserId == user.Id);
int usedCalls;
long usedTokens;
if (_db.Database.IsSqlite())
{
var used = (await interactions
.Select(x => new { x.CreatedAtUtc, x.EstimatedTokenCount })
.ToListAsync(ct))
.Where(x => x.CreatedAtUtc >= monthStart)
.ToList();
usedCalls = used.Count;
usedTokens = used.Sum(x => (long)x.EstimatedTokenCount);
}
else
{
var used = await interactions
.Where(x => x.CreatedAtUtc >= monthStart)
.GroupBy(_ => 1)
.Select(g => new { Calls = g.Count(), Tokens = g.Sum(x => (long)x.EstimatedTokenCount) })
.FirstOrDefaultAsync(ct);
usedCalls = used?.Calls ?? 0;
usedTokens = used?.Tokens ?? 0;
}
if (usedCalls >= entitlements.MonthlyAiCalls)
return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI limit reached ({entitlements.MonthlyAiCalls} generations). Upgrade your plan or try again next month.");
if ((used?.Tokens ?? 0) >= entitlements.MonthlyAiTokens)
if (usedTokens >= entitlements.MonthlyAiTokens)
return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI cost limit reached ({entitlements.MonthlyAiTokens:N0} estimated tokens). Upgrade your plan or try again next month.");
}
@@ -21,15 +21,17 @@ namespace JobTrackerApi.Controllers
".pdf", ".doc", ".docx", ".txt", ".rtf", ".png", ".jpg", ".jpeg", ".webp"
};
private readonly AppPaths _paths;
private readonly JobTrackerContext _db;
private readonly UserManager<ApplicationUser>? _users;
private readonly IAttachmentStorage _storage;
private readonly ILogger<AttachmentsController> _logger;
public AttachmentsController(AppPaths paths, JobTrackerContext db, UserManager<ApplicationUser>? users = null)
public AttachmentsController(AppPaths paths, JobTrackerContext db, UserManager<ApplicationUser>? users = null, IAttachmentStorage? storage = null, ILogger<AttachmentsController>? logger = null)
{
_paths = paths;
_db = db;
_users = users;
_storage = storage ?? new AttachmentStorage(paths);
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<AttachmentsController>.Instance;
}
public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize, string? Purpose, bool UseForAi);
@@ -104,8 +106,14 @@ namespace JobTrackerApi.Controllers
var att = await FindOwnedAttachmentAsync(id, cancellationToken);
if (att is null) return NotFound();
if (string.IsNullOrWhiteSpace(att.FilePath) || !System.IO.File.Exists(att.FilePath))
if (string.IsNullOrWhiteSpace(att.FilePath) || !_storage.IsManagedPath(att.FilePath))
return Conflict("The attachment storage path is invalid.");
if (!System.IO.File.Exists(att.FilePath))
{
if (System.IO.File.Exists(_storage.StagePath(att.FilePath)))
return Conflict("The attachment is still being finalized. Try again after the service restarts.");
return NotFound();
}
var contentType = string.IsNullOrWhiteSpace(att.FileType) ? "application/octet-stream" : att.FileType;
var fileName = Path.GetFileName(att.FileName);
@@ -132,40 +140,29 @@ namespace JobTrackerApi.Controllers
}
var rawName = (request.FileName ?? string.Empty).Trim();
if (rawName.Length == 0)
if (rawName.Length > 0)
{
await _db.SaveChangesAsync(cancellationToken);
if (purposeChanged)
{
// Recompute needs the Purpose change committed first -- a fresh query
// wouldn't see the pending change yet.
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
}
return NoContent();
var name = Path.GetFileName(rawName);
var ext = Path.GetExtension(name);
if (!AllowedExtensions.Contains(ext))
return BadRequest("That file type is not allowed.");
// The generated storage name is intentionally stable. A user-visible rename is metadata,
// so no filesystem/DB split can leave the row pointing at a moved file.
att.FileName = name;
}
var name = Path.GetFileName(rawName);
var ext = Path.GetExtension(name);
if (!AllowedExtensions.Contains(ext))
return BadRequest("That file type is not allowed.");
var folder = Path.GetDirectoryName(att.FilePath) ?? _paths.AttachmentsRoot;
var newPath = Path.Combine(folder, BuildStoredFileName(name));
if (System.IO.File.Exists(att.FilePath) && !string.Equals(att.FilePath, newPath, StringComparison.OrdinalIgnoreCase))
{
System.IO.File.Move(att.FilePath, newPath, overwrite: false);
}
att.FileName = name;
att.FilePath = newPath;
await using var transaction = _db.Database.IsRelational()
? await _db.Database.BeginTransactionAsync(cancellationToken)
: null;
await _db.SaveChangesAsync(cancellationToken);
if (purposeChanged)
{
// This query must see the new persisted purpose; the transaction keeps both saves atomic.
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
}
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return NoContent();
}
@@ -178,19 +175,77 @@ namespace JobTrackerApi.Controllers
var path = att.FilePath;
var jobId = att.JobApplicationId;
_db.Attachments.Remove(att);
await _db.SaveChangesAsync(cancellationToken);
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(path) && !_storage.IsManagedPath(path))
return Conflict("The attachment storage path is invalid.");
var deletePath = string.IsNullOrWhiteSpace(path) ? null : _storage.DeletePath(path);
var quarantined = false;
if (!string.IsNullOrWhiteSpace(path) && System.IO.File.Exists(path))
{
_storage.Quarantine(path, deletePath!);
quarantined = true;
}
else if (deletePath is not null && System.IO.File.Exists(deletePath))
{
return Accepted(new { recoveryPending = true });
}
else if (!string.IsNullOrWhiteSpace(path))
{
_logger.LogWarning("Attachment {AttachmentId} metadata referenced missing bytes; deleting the stale row.", id);
}
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? transaction = null;
var rolledBack = false;
try
{
if (!string.IsNullOrWhiteSpace(path) && System.IO.File.Exists(path))
System.IO.File.Delete(path);
if (_db.Database.IsRelational())
transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
_db.Attachments.Remove(att);
await _db.SaveChangesAsync(cancellationToken);
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
}
catch
{
// best effort
if (transaction is not null)
{
try
{
await transaction.RollbackAsync(CancellationToken.None);
rolledBack = true;
}
catch (Exception rollbackError)
{
_logger.LogWarning(rollbackError, "Attachment {AttachmentId} transaction outcome is uncertain; quarantined bytes await startup reconciliation.", id);
}
}
if (rolledBack && quarantined && deletePath is not null && System.IO.File.Exists(deletePath) && !System.IO.File.Exists(path))
{
try { _storage.Restore(deletePath, path); }
catch (Exception restoreError)
{
_logger.LogWarning(restoreError, "Attachment {AttachmentId} bytes could not be restored after database rollback; startup reconciliation will retry.", id);
}
}
throw;
}
finally
{
if (transaction is not null) await transaction.DisposeAsync();
}
if (deletePath is not null)
{
try
{
_storage.Purge(deletePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Attachment {AttachmentId} metadata was deleted; quarantined bytes await startup reconciliation.", id);
return Accepted(new { recoveryPending = true });
}
}
return NoContent();
@@ -218,9 +273,7 @@ namespace JobTrackerApi.Controllers
return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Storage limit reached ({limit / 1_000_000} MB). Remove files or upgrade your plan.");
}
var folder = Path.Combine(_paths.AttachmentsRoot, jobId.ToString());
Directory.CreateDirectory(folder);
var validFiles = new List<(IFormFile File, string DisplayName, string ContentType, string Purpose, string FinalPath, string StagePath)>();
foreach (var file in files)
{
if (file.Length == 0) continue;
@@ -232,30 +285,109 @@ namespace JobTrackerApi.Controllers
if (!AllowedExtensions.Contains(ext))
return BadRequest($"{displayName} is not an allowed file type.");
// Store uploads under unique generated filenames so re-uploads never overwrite
// earlier files with the same visible name.
var storedName = BuildStoredFileName(displayName);
var path = Path.Combine(folder, storedName);
await using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None);
await file.CopyToAsync(stream, cancellationToken);
_db.Attachments.Add(new Attachment
{
JobApplicationId = jobId,
FileName = displayName,
FilePath = path,
UploadDate = DateTime.Now,
FileType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
FileSize = file.Length,
Purpose = GuessPurpose(displayName),
UseForAi = true,
});
var finalPath = _storage.CreateFinalPath(jobId, storedName);
validFiles.Add((
file,
displayName,
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
GuessPurpose(displayName),
finalPath,
_storage.StagePath(finalPath)));
}
await _db.SaveChangesAsync(cancellationToken);
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
return Ok();
if (validFiles.Count == 0) return BadRequest("At least one non-empty file is required.");
var stagedPaths = new List<string>();
try
{
foreach (var item in validFiles)
{
await _storage.StageAsync(item.File, item.StagePath, cancellationToken);
stagedPaths.Add(item.StagePath);
}
}
catch
{
foreach (var stagedPath in stagedPaths)
{
try { _storage.Purge(stagedPath); } catch { }
}
throw;
}
Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction? transaction = null;
var rolledBack = false;
try
{
if (_db.Database.IsRelational())
transaction = await _db.Database.BeginTransactionAsync(cancellationToken);
foreach (var item in validFiles)
{
_db.Attachments.Add(new Attachment
{
JobApplicationId = jobId,
FileName = item.DisplayName,
FilePath = item.FinalPath,
UploadDate = DateTime.Now,
FileType = item.ContentType,
FileSize = item.File.Length,
Purpose = item.Purpose,
UseForAi = true,
});
}
await _db.SaveChangesAsync(cancellationToken);
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
}
catch
{
if (transaction is not null)
{
try
{
await transaction.RollbackAsync(CancellationToken.None);
rolledBack = true;
}
catch (Exception rollbackError)
{
_logger.LogWarning(rollbackError, "Attachment upload transaction outcome is uncertain; staged bytes await startup reconciliation.");
}
}
if (rolledBack)
{
foreach (var item in validFiles)
{
try { _storage.Purge(item.StagePath); }
catch (Exception purgeError)
{
_logger.LogWarning(purgeError, "Attachment upload rolled back but staged bytes could not be removed; startup reconciliation will retry.");
}
}
}
throw;
}
finally
{
if (transaction is not null) await transaction.DisposeAsync();
}
var pending = false;
foreach (var item in validFiles)
{
try
{
_storage.Promote(item.StagePath, item.FinalPath);
}
catch (Exception ex)
{
pending = true;
_logger.LogWarning(ex, "Attachment upload committed for job {JobId}; staged bytes await startup reconciliation.", jobId);
}
}
return pending ? Accepted(new { recoveryPending = true }) : Ok();
}
}
}
+319 -59
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Security.Claims;
using System.ComponentModel.DataAnnotations;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
@@ -26,8 +27,9 @@ public sealed class AuthController : ControllerBase
private readonly JobTrackerContext _db;
private readonly string _avatarDataRoot;
private readonly IHttpClientFactory? _httpClients;
private readonly ExternalOrigin _externalOrigin;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null, IHttpClientFactory? httpClients = null)
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null, IHttpClientFactory? httpClients = null, ExternalOrigin? externalOrigin = null)
{
_cfg = cfg;
_users = users;
@@ -39,6 +41,7 @@ public sealed class AuthController : ControllerBase
_twoFactorPending = twoFactorPending;
_db = db;
_httpClients = httpClients;
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
_avatarDataRoot = Path.GetFullPath((_cfg["Data:Root"] ?? environment?.ContentRootPath ?? AppContext.BaseDirectory).Trim());
}
@@ -70,6 +73,7 @@ public sealed class AuthController : ControllerBase
public sealed record LoginRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
public sealed record AuthSessionResult(bool Authenticated, string Provider);
public sealed record RegistrationPendingResult(bool VerificationRequired);
public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
@@ -89,6 +93,7 @@ public sealed class AuthController : ControllerBase
AccountEntitlements Entitlements,
GoogleLinkDto? GoogleLink,
MicrosoftLinkDto? MicrosoftLink);
public sealed record PendingEmailChangeResult(string? PendingEmail, DateTimeOffset? RequestedAtUtc);
private const int MaxAvatarBytes = 1_000_000;
private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
{
@@ -96,7 +101,10 @@ public sealed class AuthController : ControllerBase
};
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
public sealed record GoogleTokenRequest(string Token, bool RememberMe = true);
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true);
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true, string? CurrentPassword = null);
public sealed record MicrosoftLegacyRelinkRequiredResult(bool LegacyRelinkRequired);
public sealed record ConfirmMicrosoftLegacyRelinkRequest(string UserId, string TenantId, string ObjectId, string RecoveryToken, string MicrosoftToken);
public sealed record MicrosoftUnlinkRequest(string CurrentPassword);
[HttpPost("login")]
[AllowAnonymous]
@@ -175,6 +183,8 @@ public sealed class AuthController : ControllerBase
// created either way, the user can request a fresh link via resend-verification-email.
_logger.LogError(ex, "Failed to send verification email to {Email}", user.Email);
}
return StatusCode(StatusCodes.Status202Accepted, new RegistrationPendingResult(true));
}
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
@@ -295,22 +305,50 @@ public sealed class AuthController : ControllerBase
return Unauthorized(ex.Message);
}
if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId))
return Unauthorized("Microsoft token is missing its stable identity.");
var user = await _users.Users.FirstOrDefaultAsync(
x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email),
x => x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId,
cancellationToken);
if (user is null && microsoft.EmailVerified && !string.IsNullOrWhiteSpace(microsoft.Email))
if (user is null)
{
user = await _users.FindByEmailAsync(microsoft.Email);
if (user is not null)
var legacyCandidates = await _users.Users
.Where(x => x.MicrosoftTenantId == null && x.MicrosoftObjectId == null)
.Where(x => x.MicrosoftSubject == objectId || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email))
.ToListAsync(cancellationToken);
if (legacyCandidates.Count > 1)
return Conflict("This legacy Microsoft link requires administrator-assisted recovery.");
if (legacyCandidates.Count == 1)
{
_logger.LogInformation("Auto-linking Microsoft sign-in for existing local account {Email}", microsoft.Email);
var legacy = legacyCandidates[0];
if (!legacy.EmailConfirmed || string.IsNullOrWhiteSpace(legacy.Email))
return Conflict("This legacy Microsoft link requires administrator-assisted recovery.");
var purpose = MicrosoftLegacyRelinkPurpose(tenantId, objectId);
var recoveryToken = await _users.GenerateUserTokenAsync(legacy, TokenOptions.DefaultProvider, purpose);
var link = _externalOrigin.BuildPath($"/microsoft-legacy-relink?userId={Uri.EscapeDataString(legacy.Id)}&tenantId={Uri.EscapeDataString(tenantId)}&objectId={Uri.EscapeDataString(objectId)}&token={Uri.EscapeDataString(recoveryToken)}");
try
{
await _email.SendAsync(
legacy.Email,
"Confirm your Microsoft account relink",
$"A tenant-qualified Microsoft account requested access to your Jobbjakt account. If this was you, open the link and authenticate with the same Microsoft account:\n\n{link}\n\nIf this was not you, ignore this email.",
cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send Microsoft legacy-relink proof");
return EmailDeliveryUnavailable("Microsoft account recovery email could not be sent right now. Please try again later.");
}
return Accepted(new MicrosoftLegacyRelinkRequiredResult(true));
}
}
if (user is null)
{
if (!microsoft.EmailVerified || string.IsNullOrWhiteSpace(microsoft.Email))
if (string.IsNullOrWhiteSpace(microsoft.Email))
{
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
@@ -321,36 +359,95 @@ public sealed class AuthController : ControllerBase
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
user = new ApplicationUser { UserName = microsoft.Email, Email = microsoft.Email, EmailConfirmed = true };
if (await _users.FindByEmailAsync(microsoft.Email) is not null)
return Conflict("Sign in to the existing Jobbjakt account before linking Microsoft.");
user = new ApplicationUser
{
UserName = microsoft.Email,
Email = microsoft.Email,
EmailConfirmed = false,
MicrosoftTenantId = tenantId,
MicrosoftObjectId = objectId,
MicrosoftEmail = microsoft.Email,
MicrosoftLinkedAt = DateTimeOffset.UtcNow,
DisplayName = TrimOrNull(microsoft.Name),
FirstName = TrimOrNull(microsoft.GivenName),
LastName = TrimOrNull(microsoft.FamilyName),
};
var created = await _users.CreateAsync(user);
if (!created.Succeeded)
{
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
}
_logger.LogInformation("Created new user via Microsoft sign-up for {Email}", microsoft.Email);
_logger.LogInformation("Created a new tenant-qualified Microsoft user");
if (_cfg.GetValue("Auth:RequireEmailVerification", false))
{
try { await SendVerificationEmailAsync(user, cancellationToken); }
catch (Exception ex) { _logger.LogError(ex, "Failed to send verification email for Microsoft registration"); }
return StatusCode(StatusCodes.Status202Accepted, new RegistrationPendingResult(true));
}
}
if (string.IsNullOrWhiteSpace(user.MicrosoftSubject) || !string.Equals(user.MicrosoftSubject, microsoft.Subject, StringComparison.Ordinal))
{
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
await _users.UpdateAsync(user);
}
if (_cfg.GetValue("Auth:RequireEmailVerification", false) && !user.EmailConfirmed)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
var metadataUpdate = await _users.UpdateAsync(user);
if (!metadataUpdate.Succeeded) return BadRequest(string.Join("; ", metadataUpdate.Errors.Select(x => x.Description)));
return await CompleteSignInAsync(user, request.RememberMe, "microsoft", cancellationToken);
}
[HttpPost("microsoft/legacy-relink/confirm")]
[AllowAnonymous]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> ConfirmMicrosoftLegacyRelink([FromBody] ConfirmMicrosoftLegacyRelinkRequest request, CancellationToken cancellationToken)
{
MicrosoftTokenPrincipal microsoft;
try { microsoft = await _microsoftTokens.ValidateAsync(request.MicrosoftToken, cancellationToken); }
catch (Exception ex) { return BadRequest(ex.Message); }
if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId)
|| !string.Equals(tenantId, request.TenantId, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(objectId, request.ObjectId, StringComparison.OrdinalIgnoreCase))
return BadRequest("Microsoft identity does not match this recovery link.");
var user = await _users.FindByIdAsync(request.UserId);
if (user is null || user.MicrosoftTenantId is not null || user.MicrosoftObjectId is not null)
return BadRequest("Invalid or expired recovery link.");
if (!await _users.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, MicrosoftLegacyRelinkPurpose(tenantId, objectId), request.RecoveryToken))
return BadRequest("Invalid or expired recovery link.");
if (await _users.Users.AnyAsync(x => x.Id != user.Id && x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId, cancellationToken))
return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
user.MicrosoftTenantId = tenantId;
user.MicrosoftObjectId = objectId;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
ClearSessionCookies();
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
return NoContent();
}
[HttpPost("logout")]
// Anonymous on purpose, and now explicitly: this only clears the caller's own session cookies and
// leaks nothing. Requiring authentication would mean a user whose token has already expired gets a
// 401 when signing out and stays stuck in a half-signed-in state.
[AllowAnonymous]
public IActionResult Logout()
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
{
var cookieToken = Request.Cookies[AuthSessionOptions.SessionCookieName];
if (SessionRevocation.TryReadIdentity(User, cookieToken, out var userId, out var sessionId))
await SessionRevocation.RevokeCurrentAsync(_db, userId, sessionId, cancellationToken);
ClearSessionCookies();
return NoContent();
}
@@ -417,12 +514,8 @@ public sealed class AuthController : ControllerBase
// - "value" -> set (trimmed)
// This lets /profile save identity fields and /career save the master-profile fields
// through the same endpoint without one wiping the other. Email and UserName are the
// login identifiers and are never cleared to empty.
if (request.Email is not null)
{
var v = request.Email.Trim();
if (v.Length > 0) user.Email = v;
}
// login identifiers and are never cleared to empty. Email ownership changes use the
// separate, token-confirmed email-change flow below.
if (request.UserName is not null)
{
var v = request.UserName.Trim();
@@ -441,6 +534,145 @@ public sealed class AuthController : ControllerBase
return NoContent();
}
public sealed record RequestEmailChangeRequest(string Email, string CurrentPassword);
public sealed record ConfirmEmailChangeRequest(string UserId, string Email, string Token);
public sealed record CancelEmailChangeRequest(string CurrentPassword);
[HttpGet("email-change")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<PendingEmailChangeResult>> GetEmailChange()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return Ok(new PendingEmailChangeResult(user.PendingEmail, user.PendingEmailRequestedAtUtc));
}
[HttpPost("email-change/request")]
[Authorize(AuthenticationSchemes = "local")]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> RequestEmailChange([FromBody] RequestEmailChangeRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!user.EmailConfirmed) return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
return BadRequest("Current password is incorrect.");
var newEmail = (request.Email ?? string.Empty).Trim();
if (newEmail.Length > 320 || !new EmailAddressAttribute().IsValid(newEmail)) return BadRequest("A valid email is required.");
if (string.Equals(_users.NormalizeEmail(newEmail), _users.NormalizeEmail(user.Email), StringComparison.Ordinal)) return BadRequest("The new email must be different.");
var existing = await _users.FindByEmailAsync(newEmail);
if (existing is not null && !string.Equals(existing.Id, user.Id, StringComparison.Ordinal)) return BadRequest("Email is already in use.");
user.PendingEmail = newEmail;
user.PendingEmailRequestedAtUtc = DateTimeOffset.UtcNow;
user.SecurityStamp = Guid.NewGuid().ToString();
var update = await _users.UpdateAsync(user);
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));
var token = await _users.GenerateChangeEmailTokenAsync(user, newEmail);
var link = _externalOrigin.BuildPath($"/confirm-email-change?userId={Uri.EscapeDataString(user.Id)}&email={Uri.EscapeDataString(newEmail)}&token={Uri.EscapeDataString(token)}");
try
{
await _email.SendAsync(newEmail, "Confirm your new email", $"Confirm this email address for your Jobbjakt account:\n\n{link}\n\nIf you did not request this change, ignore this email.", cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send an email-change confirmation");
return EmailDeliveryUnavailable("The confirmation email could not be sent right now. Please try again later.");
}
if (!string.IsNullOrWhiteSpace(user.Email))
{
try
{
await _email.SendAsync(user.Email, "Email change requested", "A change to the email address on your Jobbjakt account was requested. Your current email remains active until the new address is confirmed.", cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send the current-address email-change notice");
}
}
return Accepted(new PendingEmailChangeResult(user.PendingEmail, user.PendingEmailRequestedAtUtc));
}
[HttpPost("email-change/confirm")]
[AllowAnonymous]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> ConfirmEmailChange([FromBody] ConfirmEmailChangeRequest request, CancellationToken cancellationToken)
{
var userId = (request.UserId ?? string.Empty).Trim();
var newEmail = (request.Email ?? string.Empty).Trim();
var token = request.Token ?? string.Empty;
if (userId.Length == 0 || newEmail.Length == 0 || token.Length == 0) return BadRequest("Invalid or expired link.");
var user = await _users.FindByIdAsync(userId);
if (user is null || !string.Equals(_users.NormalizeEmail(user.PendingEmail), _users.NormalizeEmail(newEmail), StringComparison.Ordinal))
return BadRequest("Invalid or expired link.");
var oldEmail = user.Email;
var updateUserName = string.Equals(_users.NormalizeName(user.UserName), _users.NormalizeEmail(oldEmail), StringComparison.Ordinal);
var transaction = _db.Database.IsRelational() ? await _db.Database.BeginTransactionAsync(cancellationToken) : null;
try
{
var changed = await _users.ChangeEmailAsync(user, newEmail, token);
if (!changed.Succeeded) return BadRequest("Invalid or expired link.");
if (updateUserName)
{
var renamed = await _users.SetUserNameAsync(user, newEmail);
if (!renamed.Succeeded) return BadRequest(string.Join("; ", renamed.Errors.Select(x => x.Description)));
}
user.PendingEmail = null;
user.PendingEmailRequestedAtUtc = null;
var cleared = await _users.UpdateAsync(user);
if (!cleared.Succeeded) return BadRequest(string.Join("; ", cleared.Errors.Select(x => x.Description)));
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
}
finally
{
if (transaction is not null) await transaction.DisposeAsync();
}
ClearSessionCookies();
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
if (!string.IsNullOrWhiteSpace(oldEmail))
{
try
{
await _email.SendAsync(oldEmail, "Your Jobbjakt email changed", "The email address on your Jobbjakt account was changed. If this was not you, reset your password and contact the administrator.", cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send the old-address email-change notice");
}
}
return NoContent();
}
[HttpPost("email-change/cancel")]
[Authorize(AuthenticationSchemes = "local")]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> CancelEmailChange([FromBody] CancelEmailChangeRequest request)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
return BadRequest("Current password is incorrect.");
user.PendingEmail = null;
user.PendingEmailRequestedAtUtc = null;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));
return NoContent();
}
[HttpPost("google/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<GoogleLinkDto>> LinkGoogle([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
@@ -534,15 +766,21 @@ public sealed class AuthController : ControllerBase
return BadRequest(ex.Message);
}
if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId))
return BadRequest("Microsoft token is missing its stable identity.");
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
return BadRequest("Current password is required to link Microsoft.");
var conflict = await _users.Users
.Where(x => x.Id != user.Id)
.FirstOrDefaultAsync(x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email), cancellationToken);
.FirstOrDefaultAsync(x => x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId, cancellationToken);
if (conflict is not null)
{
return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
}
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftTenantId = tenantId;
user.MicrosoftObjectId = objectId;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
@@ -555,12 +793,16 @@ public sealed class AuthController : ControllerBase
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
ClearSessionCookies();
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
return Ok(new MicrosoftLinkDto(true, user.MicrosoftEmail, user.MicrosoftLinkedAt));
}
[HttpDelete("microsoft/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> UnlinkMicrosoft()
public async Task<IActionResult> UnlinkMicrosoft([FromBody] MicrosoftUnlinkRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null)
@@ -568,7 +810,12 @@ public sealed class AuthController : ControllerBase
return Unauthorized();
}
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
return BadRequest("A current password is required before unlinking Microsoft.");
user.MicrosoftSubject = null;
user.MicrosoftTenantId = null;
user.MicrosoftObjectId = null;
user.MicrosoftEmail = null;
user.MicrosoftLinkedAt = null;
@@ -578,6 +825,10 @@ public sealed class AuthController : ControllerBase
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
ClearSessionCookies();
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
return NoContent();
}
@@ -656,7 +907,7 @@ public sealed class AuthController : ControllerBase
[HttpPost("change-password")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null)
@@ -671,6 +922,10 @@ public sealed class AuthController : ControllerBase
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
var currentTrustedDevice = TrustedDeviceService.CurrentDeviceTokenHash(Request);
await SessionRevocation.RevokeAllAsync(_db, user.Id, currentTrustedDevice, cancellationToken);
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, false, _externalOrigin.UsesHttps, cancellationToken);
return NoContent();
}
@@ -685,20 +940,14 @@ public sealed class AuthController : ControllerBase
if (email.Length == 0) return NoContent();
var user = await _users.FindByEmailAsync(email);
if (user is null || string.IsNullOrWhiteSpace(user.Email))
if (user is null || string.IsNullOrWhiteSpace(user.Email) || !user.EmailConfirmed || !await _users.HasPasswordAsync(user))
{
return NoContent();
}
var token = await _users.GeneratePasswordResetTokenAsync(user);
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = $"{Request.Scheme}://{Request.Host}";
}
var link = $"{baseUrl}/reset-password?email={Uri.EscapeDataString(user.Email)}&token={Uri.EscapeDataString(token)}";
var link = _externalOrigin.BuildPath($"/reset-password?email={Uri.EscapeDataString(user.Email)}&token={Uri.EscapeDataString(token)}");
try
{
@@ -723,7 +972,7 @@ public sealed class AuthController : ControllerBase
[HttpPost("reset-password")]
[AllowAnonymous]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? string.Empty).Trim();
var token = request.Token ?? string.Empty;
@@ -740,6 +989,14 @@ public sealed class AuthController : ControllerBase
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
if (SessionRevocation.TryReadIdentity(User, Request.Cookies[AuthSessionOptions.SessionCookieName], out var currentUserId, out _)
&& string.Equals(currentUserId, user.Id, StringComparison.Ordinal))
{
ClearSessionCookies();
}
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
return NoContent();
}
@@ -803,13 +1060,7 @@ public sealed class AuthController : ControllerBase
{
var token = await _users.GenerateEmailConfirmationTokenAsync(user);
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = $"{Request.Scheme}://{Request.Host}";
}
var link = $"{baseUrl}/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}";
var link = _externalOrigin.BuildPath($"/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}");
await _email.SendAsync(
user.Email!,
@@ -837,32 +1088,30 @@ public sealed class AuthController : ControllerBase
// (no cookie, wrong user, expired, revoked) -- never errors, just doesn't skip.
if (user.TwoFactorEnabled && await TrustedDeviceService.IsDeviceTrustedAsync(_db, Request, user.Id, cancellationToken))
{
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken);
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, _externalOrigin.UsesHttps, cancellationToken);
return Ok(new AuthSessionResult(true, provider));
}
if (user.TwoFactorEnabled)
{
var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe);
var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe, user.SecurityStamp);
return Ok(new TwoFactorRequiredResult(true, pendingToken));
}
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken);
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, _externalOrigin.UsesHttps, cancellationToken);
return Ok(new AuthSessionResult(true, provider));
}
private void EnsureCsrfCookie(bool persistent, bool? secureOverride = null)
private void EnsureCsrfCookie(bool persistent)
{
var secure = secureOverride ?? Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
var csrf = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
Response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(persistent, secure));
Response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(persistent, _externalOrigin.UsesHttps));
}
private void ClearSessionCookies()
{
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure));
Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(secure));
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(_externalOrigin.UsesHttps));
Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(_externalOrigin.UsesHttps));
}
private static string? DetectAvatarContentType(byte[] bytes)
@@ -909,9 +1158,20 @@ public sealed class AuthController : ControllerBase
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static bool TryGetMicrosoftKey(MicrosoftTokenPrincipal principal, out string tenantId, out string objectId)
{
tenantId = Guid.TryParse(principal.TenantId, out var tenant) ? tenant.ToString("D") : string.Empty;
objectId = Guid.TryParse(principal.ObjectId, out var obj) ? obj.ToString("D") : string.Empty;
return tenantId.Length > 0 && objectId.Length > 0;
}
private static string MicrosoftLegacyRelinkPurpose(string tenantId, string objectId)
=> $"microsoft-legacy-relink:{tenantId}:{objectId}";
private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
{
var entitlements = AccountPlans.ForRoles(roles);
var planEntitlements = AccountPlans.ForRoles(roles);
var entitlements = planEntitlements with { Ai = planEntitlements.Ai && user.AiEnabled };
return new MeResult(
Provider: "local",
Id: user.Id,
@@ -924,14 +1184,14 @@ public sealed class AuthController : ControllerBase
ProfileCvStructureJson: user.ProfileCvStructureJson,
AvatarImageDataUrl: AvatarStorage.Resolve(user.AvatarImageDataUrl),
Roles: roles,
Plan: entitlements.AdvancedAi ? "premium" : "free",
Plan: AccountPlans.Name(planEntitlements),
Entitlements: entitlements,
GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
Email: user.GoogleEmail,
LinkedAt: user.GoogleLinkedAt),
MicrosoftLink: new MicrosoftLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftSubject),
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftTenantId) && !string.IsNullOrWhiteSpace(user.MicrosoftObjectId),
Email: user.MicrosoftEmail,
LinkedAt: user.MicrosoftLinkedAt));
}
+10 -8
View File
@@ -1,5 +1,6 @@
using System.Security.Claims;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@@ -16,17 +17,20 @@ public sealed class BillingController : ControllerBase
private readonly UserManager<ApplicationUser> _users;
private readonly RoleManager<IdentityRole> _roles;
private readonly ILogger<BillingController> _logger;
private readonly ExternalOrigin _externalOrigin;
public BillingController(
IConfiguration configuration,
UserManager<ApplicationUser> users,
RoleManager<IdentityRole> roles,
ILogger<BillingController> logger)
ILogger<BillingController> logger,
ExternalOrigin? externalOrigin = null)
{
_configuration = configuration;
_users = users;
_roles = roles;
_logger = logger;
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(configuration);
}
public sealed record BillingRedirectDto(string Url);
@@ -44,7 +48,7 @@ public sealed class BillingController : ControllerBase
var entitlements = AccountPlans.ForRoles(await _users.GetRolesAsync(user));
return Ok(new BillingStatusDto(
enabled,
enabled && !entitlements.AdvancedAi,
enabled && !entitlements.Ai,
enabled && !string.IsNullOrWhiteSpace(user.StripeCustomerId)));
}
@@ -60,8 +64,8 @@ public sealed class BillingController : ControllerBase
if (user is null) return Unauthorized();
var currentRoles = await _users.GetRolesAsync(user);
if (AccountPlans.ForRoles(currentRoles).AdvancedAi)
return Conflict("This account already has Premium access.");
if (AccountPlans.ForRoles(currentRoles).Ai)
return Conflict("This account already has Pro access.");
var metadata = new Dictionary<string, string> { [UserMetadataKey] = user.Id };
var options = new Stripe.Checkout.SessionCreateOptions
@@ -224,9 +228,7 @@ public sealed class BillingController : ControllerBase
secretKey = (_configuration["Stripe:SecretKey"] ?? string.Empty).Trim();
premiumPrice = (_configuration["Stripe:PricePremium"] ?? string.Empty).Trim();
webhookSecret = (_configuration["Stripe:WebhookSecret"] ?? string.Empty).Trim();
publicBaseUrl = (_configuration["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
return secretKey.Length > 0 && premiumPrice.Length > 0 && webhookSecret.Length > 0
&& Uri.TryCreate(publicBaseUrl, UriKind.Absolute, out var uri)
&& uri.Scheme is "http" or "https";
publicBaseUrl = _externalOrigin.BaseUrl;
return secretKey.Length > 0 && premiumPrice.Length > 0 && webhookSecret.Length > 0;
}
}
@@ -42,7 +42,7 @@ public sealed class CvVariantController : ControllerBase
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var premiumThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).PremiumThemes;
var proThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes;
var themes = CvThemeCatalog.Themes.Select(t => new
{
id = t.Id,
@@ -54,8 +54,8 @@ public sealed class CvVariantController : ControllerBase
photoShape = t.PhotoShape,
supportsIcons = t.DefaultIcons,
atsFriendly = t.AtsFriendly,
premium = t.Premium,
available = premiumThemes || !t.Premium,
requiresPro = t.Premium,
available = proThemes || !t.Premium,
swatches = new[] { t.Accent, t.SidebarBg, t.Paper },
});
return Ok(themes);
@@ -87,7 +87,7 @@ public sealed class CvVariantController : ControllerBase
if (request?.Settings is not null && !CvThemeCatalog.Exists(request.Settings.ThemeId))
return BadRequest("Unknown theme.");
if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId))
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Premium.");
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro.");
var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct);
return Ok(ToDto(variant));
}
@@ -113,7 +113,7 @@ public sealed class CvVariantController : ControllerBase
var current = await _variants.GetAsync(user.Id, id, ct);
if (current is null) return NotFound();
if (!string.Equals(CvVariantSettingsJson.Deserialize(current.SettingsJson).ThemeId, settings.ThemeId, StringComparison.OrdinalIgnoreCase))
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Premium.");
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro.");
}
var variant = await _variants.SaveAsync(user.Id, id, request.Name, settings, request.Source ?? "autosave", ct);
return variant is null ? NotFound() : Ok(ToDto(variant));
@@ -197,6 +197,7 @@ public sealed class CvVariantController : ControllerBase
// AI assistance on any text area. Never mutates the profile or variant — returns a suggestion the
// user reviews and applies themselves. Reuses the existing provider abstraction (ISummarizerService).
[HttpPost("ai/assist")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<AiAssistResult>> AiAssist([FromBody] AiAssistRequest request, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
@@ -238,7 +239,7 @@ public sealed class CvVariantController : ControllerBase
}
private async Task<bool> CanUseThemeAsync(ApplicationUser user, string? themeId) =>
CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).PremiumThemes);
CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes);
private static CvRenderPerson Person(ApplicationUser user)
{
+4 -13
View File
@@ -19,15 +19,15 @@ public sealed class GmailController : ControllerBase
private readonly IGmailOAuthService _gmail;
private readonly IGmailJobMatchingService _matching;
private readonly JobTrackerContext _db;
private readonly IConfiguration _cfg;
private readonly IEmailProviderRegistry _providers;
private readonly ExternalOrigin _externalOrigin;
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg, IEmailProviderRegistry? providers = null)
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg, IEmailProviderRegistry? providers = null, ExternalOrigin? externalOrigin = null)
{
_gmail = gmail;
_matching = matching;
_db = db;
_cfg = cfg;
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
// Fall back to a single-Gmail registry so direct construction (tests) keeps working.
_providers = providers ?? new EmailProviderRegistry(new IEmailProvider[] { new GmailProvider(gmail) });
}
@@ -1011,16 +1011,7 @@ public sealed class GmailController : ControllerBase
private string GetRedirectUri()
{
var configured = (_cfg["Google:GmailRedirectUri"] ?? _cfg["Google:RedirectUri"] ?? "").Trim();
if (!string.IsNullOrWhiteSpace(configured)) return configured;
var publicBaseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
{
return $"{publicBaseUrl}/api/gmail/oauth/callback";
}
return $"{Request.Scheme}://{Request.Host}/api/gmail/oauth/callback";
return _externalOrigin.BuildPath("/api/gmail/oauth/callback");
}
}
@@ -155,8 +155,6 @@ namespace JobTrackerApi.Controllers
public sealed record JobEventDto(int Id, string Type, string? OldValue, string? NewValue, string? Note, DateTime At);
public sealed record TimelineItemDto(string Kind, DateTime At, object Data);
public sealed record AnalyticsPoint(string Month, int Applied, int Responses);
public sealed record TagPoint(string Tag, int Count);
@@ -82,6 +82,13 @@ namespace JobTrackerApi.Controllers
return await _users.FindByIdAsync(userId);
}
private async Task<bool> CanCurrentUserUseAiAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var user = await GetCurrentUserAsync(cancellationToken);
return user is not null && user.AiEnabled && AccountPlans.ForRoles(await _users.GetRolesAsync(user)).Ai;
}
private async Task<TailoredCvDraft?> FindTailoredCvDraftAsync(int jobId, CancellationToken cancellationToken)
{
return await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == jobId, cancellationToken);
@@ -622,7 +629,9 @@ Canonical profile:
var d = RulesEngine.Evaluate(settings, job, now, lm);
// Prefer translated content for the detailed summary so Norwegian postings
// surface readable English analysis while the original text remains available.
var full = await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40);
var full = await CanCurrentUserUseAiAsync(cancellationToken)
? await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40)
: null;
return Ok(BuildJobApplicationDto(job, d, fullSummary: full));
}
@@ -774,8 +783,8 @@ Canonical profile:
// Generate and persist a short summary at creation time to avoid repeated model calls.
try
{
var shortSum = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60);
job.ShortSummary = shortSum;
if (await CanCurrentUserUseAiAsync(cancellationToken))
job.ShortSummary = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60);
}
catch
{
@@ -957,6 +966,7 @@ Canonical profile:
[HttpPost("{id:int}/refresh-ai")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
@@ -1082,51 +1092,6 @@ Canonical profile:
return Ok(items);
}
[HttpGet("{id:int}/timeline")]
public async Task<ActionResult<List<TimelineItemDto>>> GetTimeline([FromRoute] int id, CancellationToken cancellationToken)
{
var exists = await _db.JobApplications.AnyAsync(j => j.Id == id, cancellationToken);
if (!exists) return NotFound();
var events = await _db.JobEvents
.AsNoTracking()
.Where(e => e.JobApplicationId == id)
.Select(e => new TimelineItemDto(
"event",
e.At,
new { e.Id, e.Type, e.OldValue, e.NewValue, e.Note }
))
.ToListAsync(cancellationToken);
var messages = await _db.Correspondences
.AsNoTracking()
.Where(c => c.JobApplicationId == id)
.Select(c => new TimelineItemDto(
"message",
c.Date,
new { c.Id, c.From, c.Subject, c.Channel, c.Content }
))
.ToListAsync(cancellationToken);
var attachments = await _db.Attachments
.AsNoTracking()
.Where(a => a.JobApplicationId == id)
.Select(a => new TimelineItemDto(
"attachment",
a.UploadDate,
new { a.Id, a.FileName, a.FileType, a.FileSize }
))
.ToListAsync(cancellationToken);
var all = events
.Concat(messages)
.Concat(attachments)
.OrderByDescending(x => x.At)
.ToList();
return Ok(all);
}
[HttpGet("stats")]
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
=> Ok(await _analytics.GetStatsAsync(cancellationToken));
@@ -1424,6 +1389,7 @@ Canonical profile:
}
[HttpGet("{id:int}/candidate-fit")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
@@ -1559,6 +1525,7 @@ Candidate CV/profile:
}
[HttpGet("{id:int}/focus-plan")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
@@ -1676,7 +1643,8 @@ Candidate master CV:
await _db.SaveChangesAsync(cancellationToken);
}
[HttpGet("{id:int}/interview-prep")]
[HttpGet("{id:int}/interview-prep/brief")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
@@ -1867,6 +1835,7 @@ Candidate master CV:
}
[HttpPost("{id:int}/generate-tailored-cv-draft")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<TailoredCvDraftDto>> GenerateTailoredCvDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
@@ -1975,6 +1944,7 @@ Candidate master CV:
}
[HttpPost("{id:int}/generate-application-package")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? coverLetterStyle, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
@@ -2240,6 +2210,7 @@ Candidate master CV:
}
[HttpGet("{id:int}/followup-draft")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<FollowUpDraftDto>> GetFollowUpDraft([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
@@ -17,12 +17,12 @@ namespace JobTrackerApi.Controllers;
public sealed class MicrosoftGraphController : ControllerBase
{
private readonly IMicrosoftGraphOAuthService _graph;
private readonly IConfiguration _cfg;
private readonly ExternalOrigin _externalOrigin;
public MicrosoftGraphController(IMicrosoftGraphOAuthService graph, IConfiguration cfg)
public MicrosoftGraphController(IMicrosoftGraphOAuthService graph, IConfiguration cfg, ExternalOrigin? externalOrigin = null)
{
_graph = graph;
_cfg = cfg;
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
}
public sealed record MicrosoftGraphConnectionStatusDto(
@@ -110,16 +110,7 @@ public sealed class MicrosoftGraphController : ControllerBase
private string GetRedirectUri()
{
var configured = (_cfg["Microsoft:RedirectUri"] ?? "").Trim();
if (!string.IsNullOrWhiteSpace(configured)) return configured;
var publicBaseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
{
return $"{publicBaseUrl}/api/microsoft-graph/oauth/callback";
}
return $"{Request.Scheme}://{Request.Host}/api/microsoft-graph/oauth/callback";
return _externalOrigin.BuildPath("/api/microsoft-graph/oauth/callback");
}
private static string BuildPopupHtml(bool success, string message)
@@ -0,0 +1,129 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/operations")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class OperationsController(UserOperationStore operations) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<OperationDto>>> List([FromQuery] int limit = 25, CancellationToken cancellationToken = default)
{
if (limit is < 1 or > 100) return BadRequest(new { code = "invalid_limit", message = "Limit must be between 1 and 100." });
return Ok((await operations.ListAsync(limit, cancellationToken)).Select(ToDto).ToList());
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<OperationDto>> Get(Guid id, CancellationToken cancellationToken)
{
var operation = await operations.GetAsync(id, cancellationToken);
return operation is null ? NotFound() : Ok(ToDto(operation));
}
[HttpPost("{id:guid}/cancel")]
public async Task<ActionResult<OperationDto>> Cancel(Guid id, CancellationToken cancellationToken)
{
if (await operations.GetAsync(id, cancellationToken) is null) return NotFound();
if (!await operations.RequestCancellationAsync(id, cancellationToken))
return Conflict(new { code = "operation_not_cancellable", message = "This operation can no longer be cancelled." });
return Ok(ToDto((await operations.GetAsync(id, cancellationToken))!));
}
[HttpPost("{id:guid}/retry")]
public async Task<ActionResult<OperationDto>> Retry(Guid id, CancellationToken cancellationToken)
{
if (await operations.GetAsync(id, cancellationToken) is null) return NotFound();
if (!await operations.RetryAsync(id, cancellationToken))
return Conflict(new { code = "operation_not_retryable", message = "Only failed or cancelled operations can be retried." });
return Ok(ToDto((await operations.GetAsync(id, cancellationToken))!));
}
private static OperationDto ToDto(UserOperation operation) => new(
operation.Id,
operation.TaskType,
operation.Status,
operation.SubjectType,
operation.CreatedAtUtc,
operation.StartedAtUtc,
operation.CompletedAtUtc,
operation.DeadlineAtUtc,
operation.CancellationRequestedAtUtc,
operation.ProgressStage,
operation.ProgressPercent,
operation.FailureCategory,
!OperationStatuses.IsTerminal(operation.Status) && operation.CancellationRequestedAtUtc is null,
operation.Status is OperationStatuses.Failed or OperationStatuses.Cancelled);
}
public sealed record OperationDto(
Guid Id,
string TaskType,
string Status,
string? SubjectType,
DateTime CreatedAtUtc,
DateTime? StartedAtUtc,
DateTime? CompletedAtUtc,
DateTime? DeadlineAtUtc,
DateTime? CancellationRequestedAtUtc,
string? ProgressStage,
int? ProgressPercent,
string? FailureCategory,
bool CanCancel,
bool CanRetry);
[ApiController]
[Route("api/notifications")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class NotificationsController(UserNotificationStore notifications) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<NotificationDto>>> List([FromQuery] int limit = 25, CancellationToken cancellationToken = default)
{
if (limit is < 1 or > 100) return BadRequest(new { code = "invalid_limit", message = "Limit must be between 1 and 100." });
return Ok((await notifications.ListAsync(limit, cancellationToken)).Select(ToDto).ToList());
}
[HttpGet("unread-count")]
public async Task<IActionResult> UnreadCount(CancellationToken cancellationToken) =>
Ok(new { count = await notifications.UnreadCountAsync(cancellationToken) });
[HttpPost("{id:guid}/read")]
public async Task<IActionResult> MarkRead(Guid id, CancellationToken cancellationToken)
{
if (await notifications.GetAsync(id, cancellationToken) is null) return NotFound();
await notifications.MarkReadAsync(id, cancellationToken);
return NoContent();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Dismiss(Guid id, CancellationToken cancellationToken)
{
if (await notifications.GetAsync(id, cancellationToken) is null) return NotFound();
await notifications.DismissAsync(id, cancellationToken);
return NoContent();
}
private static NotificationDto ToDto(UserNotification notification) => new(
notification.Id,
notification.OperationId,
notification.Kind,
notification.Title,
notification.Message,
notification.LinkPath,
notification.CreatedAtUtc,
notification.ReadAtUtc);
}
public sealed record NotificationDto(
Guid Id,
Guid? OperationId,
string Kind,
string Title,
string Message,
string? LinkPath,
DateTime CreatedAtUtc,
DateTime? ReadAtUtc);
@@ -327,6 +327,17 @@ public sealed partial class ProfileCvController : ControllerBase
return;
}
if (!user.AiEnabled || !AccountPlans.ForRoles(await _users.GetRolesAsync(user)).Ai)
{
run.Status = "failed";
run.ErrorMessage = user.AiEnabled
? "This AI feature requires Pro."
: "AI is disabled in your privacy settings.";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return;
}
run.Status = "running";
run.ErrorMessage = null;
await _db.SaveChangesAsync(cancellationToken);
@@ -416,11 +427,11 @@ public sealed partial class ProfileCvController : ControllerBase
private async Task PruneExtractionRunsAsync(string ownerUserId, CancellationToken cancellationToken)
{
var expired = await _db.CvExtractionRuns.IgnoreQueryFilters()
.Where(x => x.OwnerUserId == ownerUserId && x.Status != "queued" && x.Status != "running")
.OrderByDescending(x => x.StartedAtUtc)
.Skip(ExtractionRunRetentionCount)
.ToListAsync(cancellationToken);
var completedRuns = _db.CvExtractionRuns.IgnoreQueryFilters()
.Where(x => x.OwnerUserId == ownerUserId && x.Status != "queued" && x.Status != "running");
var expired = _db.Database.IsSqlite()
? (await completedRuns.ToListAsync(cancellationToken)).OrderByDescending(x => x.StartedAtUtc).Skip(ExtractionRunRetentionCount).ToList()
: await completedRuns.OrderByDescending(x => x.StartedAtUtc).Skip(ExtractionRunRetentionCount).ToListAsync(cancellationToken);
if (expired.Count > 0)
{
_db.CvExtractionRuns.RemoveRange(expired);
@@ -136,6 +136,7 @@ public sealed partial class ProfileCvController : ControllerBase
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
[HttpPost("upload")]
[Authorize(Policy = ProEntitlement.Policy)]
[RequestSizeLimit(MaxFileSizeBytes)]
public async Task<IActionResult> Upload([FromForm] IFormFile file)
{
@@ -206,11 +207,9 @@ public sealed partial class ProfileCvController : ControllerBase
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var runs = await _db.CvExtractionRuns
var runsQuery = _db.CvExtractionRuns
.AsNoTracking()
.Where(x => x.OwnerUserId == user.Id)
.OrderByDescending(x => x.StartedAtUtc)
.Take(10)
.Select(x => new CvExtractionRunListItem(
x.Id,
x.Trigger,
@@ -222,8 +221,10 @@ public sealed partial class ProfileCvController : ControllerBase
x.ParserVersion,
x.NormalizerVersion,
x.LlmPromptVersion,
x.ErrorMessage))
.ToListAsync(HttpContext.RequestAborted);
x.ErrorMessage));
var runs = _db.Database.IsSqlite()
? (await runsQuery.ToListAsync(HttpContext.RequestAborted)).OrderByDescending(x => x.StartedAtUtc).Take(10).ToList()
: await runsQuery.OrderByDescending(x => x.StartedAtUtc).Take(10).ToListAsync(HttpContext.RequestAborted);
return Ok(runs);
}
@@ -294,15 +295,16 @@ public sealed partial class ProfileCvController : ControllerBase
}
[HttpPost("reprocess")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<IActionResult> Reprocess()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var artifact = await _db.CvUploadArtifacts
.AsNoTracking()
.OrderByDescending(x => x.UploadedAtUtc)
.FirstOrDefaultAsync(x => x.OwnerUserId == user.Id, HttpContext.RequestAborted);
var artifactQuery = _db.CvUploadArtifacts.AsNoTracking().Where(x => x.OwnerUserId == user.Id);
var artifact = _db.Database.IsSqlite()
? (await artifactQuery.ToListAsync(HttpContext.RequestAborted)).MaxBy(x => x.UploadedAtUtc)
: await artifactQuery.OrderByDescending(x => x.UploadedAtUtc).FirstOrDefaultAsync(HttpContext.RequestAborted);
if (artifact is null) return BadRequest("Upload a CV before reprocessing it.");
if (string.IsNullOrWhiteSpace(artifact.StoragePath) || !System.IO.File.Exists(artifact.StoragePath))
@@ -316,6 +318,7 @@ public sealed partial class ProfileCvController : ControllerBase
}
[HttpPost("rebuild")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<IActionResult> Rebuild()
{
var user = await _users.GetUserAsync(User);
@@ -328,6 +331,7 @@ public sealed partial class ProfileCvController : ControllerBase
}
[HttpPost("rewrite-section")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<IActionResult> RewriteSection([FromBody] RewriteSectionRequest request)
{
var user = await _users.GetUserAsync(User);
@@ -431,6 +435,7 @@ public sealed partial class ProfileCvController : ControllerBase
}
[HttpPost("rewrite-preview")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<ProfileCvPreviewDto>> BuildRewritePreview([FromBody] RewriteSectionRequest request)
{
var user = await _users.GetUserAsync(User);
@@ -473,6 +478,7 @@ public sealed partial class ProfileCvController : ControllerBase
}
[HttpPost("export-pdf")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<IActionResult> ExportProfileCvPdf([FromBody] RewriteSectionRequest request, CancellationToken cancellationToken)
{
var previewResult = await BuildRewritePreview(request);
@@ -492,6 +498,7 @@ public sealed partial class ProfileCvController : ControllerBase
}
[HttpPost("parse")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<object>> Parse([FromBody] ParseCvRequest? request)
{
var user = await _users.GetUserAsync(User);
@@ -512,6 +519,7 @@ public sealed partial class ProfileCvController : ControllerBase
}
[HttpPost("improve")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<IActionResult> Improve()
{
var user = await _users.GetUserAsync(User);
@@ -18,11 +18,13 @@ public sealed class SessionsController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly JobTrackerContext _db;
private readonly ExternalOrigin _externalOrigin;
public SessionsController(UserManager<ApplicationUser> users, JobTrackerContext db)
public SessionsController(UserManager<ApplicationUser> users, JobTrackerContext db, ExternalOrigin? externalOrigin = null)
{
_users = users;
_db = db;
_externalOrigin = externalOrigin ?? ExternalOrigin.Parse(null, production: false);
}
public sealed record SessionDto(string Id, string? DeviceLabel, DateTimeOffset CreatedAtUtc, DateTimeOffset LastSeenAtUtc, DateTimeOffset ExpiresAtUtc, bool IsCurrentSession);
@@ -71,8 +73,7 @@ public sealed class SessionsController : ControllerBase
if (string.Equals(id, CurrentSid, StringComparison.Ordinal))
{
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure));
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(_externalOrigin.UsesHttps));
}
return NoContent();
@@ -29,8 +29,9 @@ public sealed class TwoFactorController : ControllerBase
private readonly ITwoFactorPendingTokenService _pending;
private readonly IDataProtector _protector;
private readonly IConfiguration _cfg;
private readonly ExternalOrigin _externalOrigin;
public TwoFactorController(UserManager<ApplicationUser> users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider, IConfiguration cfg)
public TwoFactorController(UserManager<ApplicationUser> users, ITokenService tokens, JobTrackerContext db, ITwoFactorPendingTokenService pending, IDataProtectionProvider protectionProvider, IConfiguration cfg, ExternalOrigin? externalOrigin = null)
{
_users = users;
_tokens = tokens;
@@ -38,6 +39,7 @@ public sealed class TwoFactorController : ControllerBase
_pending = pending;
_protector = protectionProvider.CreateProtector("totp-secret-v1");
_cfg = cfg;
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
}
public sealed record PasswordConfirmRequest(string CurrentPassword);
@@ -198,17 +200,23 @@ public sealed class TwoFactorController : ControllerBase
{
return Unauthorized();
}
if (session.SecurityStamp is not null
&& !string.Equals(session.SecurityStamp, user.SecurityStamp, StringComparison.Ordinal))
{
_pending.Resolve(pendingToken, consume: true);
return Unauthorized();
}
var base32Secret = _protector.Unprotect(user.TotpSecretEncrypted);
var verified = VerifyCode(base32Secret, code) || await TryConsumeRecoveryCodeAsync(user.Id, code, cancellationToken);
if (!verified) return Unauthorized();
_pending.Resolve(pendingToken, consume: true);
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, session.RememberMe, cancellationToken);
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, session.RememberMe, _externalOrigin.UsesHttps, cancellationToken);
if (request.TrustDevice)
{
await TrustedDeviceService.IssueAsync(_db, Request, Response, user.Id, cancellationToken);
await TrustedDeviceService.IssueAsync(_db, Request, Response, user.Id, _externalOrigin.UsesHttps, cancellationToken);
}
return Ok(new AuthController.AuthSessionResult(true, "local"));
@@ -250,7 +258,7 @@ public sealed class TwoFactorController : ControllerBase
if (isCurrentDevice)
{
TrustedDeviceService.ClearCookie(Request, Response);
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
}
return NoContent();
@@ -270,7 +278,7 @@ public sealed class TwoFactorController : ControllerBase
await _db.SaveChangesAsync(cancellationToken);
}
TrustedDeviceService.ClearCookie(Request, Response);
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
return NoContent();
}
+4 -10
View File
@@ -17,14 +17,14 @@ public sealed class UsersController : ControllerBase
private readonly UserManager<ApplicationUser> _users;
private readonly RoleManager<IdentityRole> _roles;
private readonly IAppEmailSender _email;
private readonly IConfiguration _cfg;
private readonly ExternalOrigin _externalOrigin;
private readonly ILogger<UsersController> _logger;
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger)
public UsersController(UserManager<ApplicationUser> users, RoleManager<IdentityRole> roles, IAppEmailSender email, IConfiguration cfg, ILogger<UsersController> logger, ExternalOrigin? externalOrigin = null)
{
_users = users;
_roles = roles;
_email = email;
_cfg = cfg;
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
_logger = logger;
}
@@ -146,13 +146,7 @@ public sealed class UsersController : ControllerBase
var token = await _users.GeneratePasswordResetTokenAsync(u);
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = $"{Request.Scheme}://{Request.Host}";
}
var link = $"{baseUrl}/reset-password?email={Uri.EscapeDataString(u.Email)}&token={Uri.EscapeDataString(token)}";
var link = _externalOrigin.BuildPath($"/reset-password?email={Uri.EscapeDataString(u.Email)}&token={Uri.EscapeDataString(token)}");
try
{