02b38f7acb
Phase 5.4. Connects the career outputs a user already has to one job
application, without building a second copy of any of them.
The flow is strictly one-directional — CareerProfile -> CvVariant ->
application output — and nothing writes back up. No code path in this phase
touches CareerProfile or its children.
CV integration re-points rather than duplicates. GET/PUT /{id}/cv attaches one
variant to an application via CvVariant.JobApplicationId; replacing detaches the
previous variant instead of deleting it. Creating, duplicating, editing, theming,
previewing, exporting PDF and version history all stay in the existing CV
builder, which the section links into. There is no second CV system.
Tailoring composes the Phase 5.3 analysis and match into skills to highlight,
experience to prioritise, projects to emphasise, keywords to include and gaps to
address. Deterministic and advisory: it says what the user could emphasise and
the user edits the variant themselves. Nothing auto-applies.
Cover letters gain the history they were missing. JobApplication.CoverLetterText
stays the current text with its API contract unchanged; CoverLetterVersions
records what it used to be, so an AI rewrite is never destructive. Restore is
additive — the old text comes back as a new version, so what you restored from
still exists. Source and AiAction record whether the user wrote a version or
approved it from a suggestion, and an AI generation only becomes a version once
the user saves it.
Documents are untouched: the existing Attachment system already covers CV, cover
letter, certificates and portfolio files with a Purpose field, so the workspace
mounts that component rather than adding a second upload path.
CoverLetterVersions is the only new table — reconciler-owned, no-op migration,
guarded on JobApplications, and verified on a fresh MariaDB 11: int
AUTO_INCREMENT primary key, varchar(255) owner, datetime(6), composite index
inside the key limit.
360 backend tests, 115 frontend tests, type check, Release build and the
production build all pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
529 lines
21 KiB
C#
529 lines
21 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
|
using JobTrackerApi.Controllers;
|
|
using JobTrackerApi.Data;
|
|
using System.Data.Common;
|
|
using MySqlConnector;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using System.Diagnostics;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Net;
|
|
using System.IO;
|
|
using System.Security.Cryptography;
|
|
using System.Threading.RateLimiting;
|
|
using JobTrackerApi.Services.JobImport;
|
|
using JobTrackerApi.Services.JobImport.Plugins;
|
|
using JobTrackerApi.Services.JobImport.Translation;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Avoid Windows EventLog provider issues in local dev environments.
|
|
builder.Logging.ClearProviders();
|
|
builder.Logging.AddConsole();
|
|
builder.Logging.AddDebug();
|
|
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
|
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
|
|
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
|
|
builder.Services.AddSingleton<ICvProcessingQueue, CvProcessingQueue>();
|
|
builder.Services.AddTransient<ProfileCvController>();
|
|
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
|
|
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
|
|
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
|
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
|
|
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
|
|
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
|
|
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
|
|
builder.Services.AddScoped<IApplicationChecklistService, ApplicationChecklistService>();
|
|
builder.Services.AddScoped<IApplicationTimelineService, ApplicationTimelineService>();
|
|
builder.Services.AddScoped<IApplicationIntelligenceService, ApplicationIntelligenceService>();
|
|
builder.Services.AddScoped<IApplicationAssetsService, ApplicationAssetsService>();
|
|
|
|
builder.Services.AddSingleton<AppPaths>();
|
|
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
|
|
|
|
// Add DbContext
|
|
builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
|
|
{
|
|
var cfg = sp.GetRequiredService<IConfiguration>();
|
|
var paths = sp.GetRequiredService<AppPaths>();
|
|
|
|
var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
|
|
var cs = cfg.GetConnectionString("JobTracker");
|
|
if (string.IsNullOrWhiteSpace(cs))
|
|
{
|
|
cs = $"Data Source={paths.GetDbPath()}";
|
|
provider = "sqlite";
|
|
}
|
|
|
|
if (provider is "mysql" or "mariadb")
|
|
{
|
|
// Avoid ServerVersion.AutoDetect here because it forces an immediate DB connection
|
|
// during service registration, which can crash the API if MariaDB is temporarily
|
|
// unavailable or on a different network during deploy startup.
|
|
options.UseMySql(cs, new MariaDbServerVersion(new Version(11, 0, 0)), mysql =>
|
|
{
|
|
mysql.MigrationsAssembly("JobTrackerApi");
|
|
});
|
|
}
|
|
else
|
|
{
|
|
options.UseSqlite(cs, sqlite =>
|
|
{
|
|
sqlite.MigrationsAssembly("JobTrackerApi");
|
|
});
|
|
}
|
|
|
|
// We create Identity tables on startup in environments where `dotnet ef` isn't available.
|
|
// That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it.
|
|
options.ConfigureWarnings(w =>
|
|
{
|
|
w.Ignore(RelationalEventId.PendingModelChangesWarning);
|
|
w.Ignore(CoreEventId.PossibleIncorrectRequiredNavigationWithQueryFilterInteractionWarning);
|
|
});
|
|
});
|
|
|
|
// Enable CORS (allowlist by default)
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddPolicy("AllowReact", policy =>
|
|
{
|
|
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
|
|
if (origins.Length == 0)
|
|
{
|
|
origins = new[] { "http://localhost:3000" };
|
|
}
|
|
|
|
if (origins.Any(x => x.Trim() == "*"))
|
|
{
|
|
policy.SetIsOriginAllowed(_ => true)
|
|
.AllowAnyMethod()
|
|
.AllowAnyHeader()
|
|
.AllowCredentials();
|
|
}
|
|
else
|
|
{
|
|
policy.WithOrigins(origins.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray())
|
|
.AllowAnyMethod()
|
|
.AllowAnyHeader()
|
|
.AllowCredentials();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Add controllers
|
|
builder.Services.AddControllers();
|
|
builder.Services.AddOpenApi();
|
|
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
|
|
if (string.IsNullOrWhiteSpace(dataRoot))
|
|
{
|
|
dataRoot = builder.Environment.ContentRootPath;
|
|
}
|
|
if (!Path.IsPathRooted(dataRoot))
|
|
{
|
|
dataRoot = Path.Combine(builder.Environment.ContentRootPath, dataRoot);
|
|
}
|
|
Directory.CreateDirectory(dataRoot);
|
|
var dataProtectionKeysPath = Path.Combine(dataRoot, "keys");
|
|
Directory.CreateDirectory(dataProtectionKeysPath);
|
|
|
|
builder.Services.AddDataProtection()
|
|
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath))
|
|
.SetApplicationName("JobTracker");
|
|
builder.Services.AddSingleton<IDatabaseBackupRunner, SqliteDatabaseBackupRunner>();
|
|
builder.Services.AddHostedService<DatabaseBackupHostedService>();
|
|
builder.Services.AddHostedService<RulesHostedService>();
|
|
builder.Services.AddHostedService<FollowUpReminderHostedService>();
|
|
builder.Services.AddHostedService<DailyExportHostedService>();
|
|
builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
|
builder.Services.AddHostedService<SummarizerProbeHostedService>();
|
|
builder.Services.AddHostedService<CvProcessingHostedService>();
|
|
|
|
builder.Services.AddHttpClient("jobimport")
|
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
|
{
|
|
AutomaticDecompression = DecompressionMethods.All,
|
|
AllowAutoRedirect = false
|
|
});
|
|
|
|
// Local AI service (FastAPI). Supports summarization and OCR/text extraction.
|
|
// Every caller goes through this named client, so the shared-secret header is set once here.
|
|
builder.Services.AddHttpClient("ai-service", client =>
|
|
{
|
|
var baseUrl = builder.Configuration["Ai:BaseUrl"]
|
|
?? builder.Configuration["Summarizer:BaseUrl"]
|
|
?? "http://127.0.0.1:8001";
|
|
client.BaseAddress = new Uri(baseUrl);
|
|
client.Timeout = TimeSpan.FromSeconds(30);
|
|
|
|
var serviceToken = builder.Configuration["Ai:ServiceToken"];
|
|
if (!string.IsNullOrWhiteSpace(serviceToken))
|
|
{
|
|
client.DefaultRequestHeaders.Add("X-Ai-Service-Token", serviceToken);
|
|
}
|
|
});
|
|
|
|
builder.Services.AddMemoryCache();
|
|
builder.Services.AddScoped<AnalyticsService>();
|
|
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
|
builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
|
|
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
|
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
|
|
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
|
builder.Services.AddSingleton<IMicrosoftTokenValidator, MicrosoftTokenValidator>();
|
|
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
|
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
|
|
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
|
builder.Services.AddScoped<IMicrosoftGraphOAuthService, MicrosoftGraphOAuthService>();
|
|
builder.Services.AddScoped<IImapService, ImapService>();
|
|
|
|
// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph + IMAP today; manual next).
|
|
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>();
|
|
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.MicrosoftGraphProvider>();
|
|
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.ImapProvider>();
|
|
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
|
|
|
|
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
|
{
|
|
options.User.RequireUniqueEmail = true;
|
|
options.Password.RequireDigit = true;
|
|
options.Password.RequireLowercase = true;
|
|
options.Password.RequireUppercase = false;
|
|
options.Password.RequireNonAlphanumeric = false;
|
|
options.Password.RequiredLength = 8;
|
|
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
|
|
options.Lockout.MaxFailedAccessAttempts = 5;
|
|
options.Lockout.AllowedForNewUsers = true;
|
|
})
|
|
.AddRoles<IdentityRole>()
|
|
.AddEntityFrameworkStores<JobTrackerContext>()
|
|
.AddSignInManager();
|
|
|
|
builder.Services.AddScoped<ITokenService, TokenService>();
|
|
builder.Services.AddSingleton<ITwoFactorPendingTokenService, TwoFactorPendingTokenService>();
|
|
|
|
builder.Services.AddSingleton<UniversalJobParser>();
|
|
builder.Services.AddSingleton<IHostAddressResolver, DnsHostAddressResolver>();
|
|
builder.Services.AddSingleton<IJobSitePlugin, FinnPlugin>();
|
|
builder.Services.AddSingleton<IJobSitePlugin, NavPlugin>();
|
|
builder.Services.AddSingleton<IJobSitePlugin, LinkedInPlugin>();
|
|
builder.Services.AddSingleton<IJobSitePlugin, JobbnorgePlugin>();
|
|
|
|
var translationProvider = (builder.Configuration["Translation:Provider"] ?? "none").Trim().ToLowerInvariant();
|
|
builder.Services.AddSingleton<ITranslationService>(sp =>
|
|
{
|
|
return translationProvider switch
|
|
{
|
|
"libretranslate" => new LibreTranslateService(sp.GetRequiredService<IHttpClientFactory>(), sp.GetRequiredService<IConfiguration>()),
|
|
_ => new NoOpTranslationService()
|
|
};
|
|
});
|
|
builder.Services.AddScoped<JobImportService>();
|
|
|
|
var requireAuth = builder.Configuration.GetValue("Auth:Require", false);
|
|
var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim();
|
|
var microsoftClientId = (builder.Configuration["Auth:MicrosoftClientId"] ?? "").Trim();
|
|
|
|
var jwtKey = (builder.Configuration["Auth:JwtKey"] ?? "").Trim();
|
|
var ephemeralJwtKey = false;
|
|
if (string.IsNullOrWhiteSpace(jwtKey))
|
|
{
|
|
if (requireAuth)
|
|
throw new InvalidOperationException("Auth is required but Auth:JwtKey is not configured.");
|
|
|
|
jwtKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
|
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:JwtKey"] = jwtKey });
|
|
ephemeralJwtKey = true;
|
|
}
|
|
|
|
var issuer = (builder.Configuration["Auth:JwtIssuer"] ?? "JobTrackerApi").Trim();
|
|
var audience = (builder.Configuration["Auth:JwtAudience"] ?? "job-tracker-ui").Trim();
|
|
|
|
builder.Services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultScheme = "smart";
|
|
options.DefaultChallengeScheme = "smart";
|
|
})
|
|
.AddPolicyScheme("smart", "Smart JWT", options =>
|
|
{
|
|
options.ForwardDefaultSelector = ctx =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(googleClientId) && string.IsNullOrWhiteSpace(microsoftClientId))
|
|
return "local";
|
|
|
|
var auth = ctx.Request.Headers.Authorization.ToString();
|
|
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
|
return "local";
|
|
|
|
var token = auth["Bearer ".Length..].Trim();
|
|
var handler = new JwtSecurityTokenHandler();
|
|
if (!handler.CanReadToken(token))
|
|
return "local";
|
|
|
|
try
|
|
{
|
|
var jwt = handler.ReadJwtToken(token);
|
|
var iss = jwt.Issuer ?? "";
|
|
if (!string.IsNullOrWhiteSpace(googleClientId) && iss is "accounts.google.com" or "https://accounts.google.com")
|
|
return "google";
|
|
if (!string.IsNullOrWhiteSpace(microsoftClientId) && iss.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase))
|
|
return "microsoft";
|
|
return "local";
|
|
}
|
|
catch
|
|
{
|
|
return "local";
|
|
}
|
|
};
|
|
})
|
|
.AddJwtBearer("local", options =>
|
|
{
|
|
options.Events = new JwtBearerEvents
|
|
{
|
|
OnMessageReceived = context =>
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(context.Token))
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
if (context.Request.Cookies.TryGetValue(AuthSessionOptions.SessionCookieName, out var cookieToken) && !string.IsNullOrWhiteSpace(cookieToken))
|
|
{
|
|
context.Token = cookieToken;
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
},
|
|
OnTokenValidated = async context =>
|
|
{
|
|
var userId = LocalAuthIdentity.GetRequiredUserId(context.Principal);
|
|
if (userId is null)
|
|
{
|
|
context.Fail("Local tokens must include a subject/nameidentifier claim.");
|
|
return;
|
|
}
|
|
|
|
// Resolve a fresh scoped JobTrackerContext for this one lookup -- OnTokenValidated
|
|
// runs outside the request's normal DI-constructor scope, so RequestServices (the
|
|
// per-request scope) must be used directly rather than a captured/singleton one.
|
|
// Fail closed if the session row is missing/revoked/expired (including tokens
|
|
// with no "sid" claim at all -- see LocalSessionValidator for why: every JWT
|
|
// issued going forward carries one, so a token without it is either pre-deploy
|
|
// (forces a single re-login for anyone already signed in when this ships --
|
|
// acceptable, same additive-forward cost the 2FA/trusted-device features on this
|
|
// branch already paid) or forged, and either way isn't proof of a live session.
|
|
var db = context.HttpContext.RequestServices.GetRequiredService<JobTrackerContext>();
|
|
if (!await LocalSessionValidator.IsValidAsync(db, context.Principal, DateTimeOffset.UtcNow))
|
|
{
|
|
context.Fail("Session has been revoked or expired.");
|
|
}
|
|
}
|
|
};
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuer = issuer,
|
|
ValidateAudience = true,
|
|
ValidAudience = audience,
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(jwtKey)),
|
|
ValidateLifetime = true,
|
|
ClockSkew = TimeSpan.FromMinutes(2),
|
|
NameClaimType = System.Security.Claims.ClaimTypes.Name,
|
|
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
|
|
};
|
|
});
|
|
|
|
if (!string.IsNullOrWhiteSpace(googleClientId))
|
|
{
|
|
builder.Services.AddAuthentication().AddJwtBearer("google", options =>
|
|
{
|
|
// Validate Google ID tokens (sent from the frontend) as bearer tokens.
|
|
options.Authority = "https://accounts.google.com";
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = true,
|
|
ValidIssuers = new[] { "accounts.google.com", "https://accounts.google.com" },
|
|
ValidateAudience = true,
|
|
ValidAudience = googleClientId,
|
|
ValidateLifetime = true,
|
|
};
|
|
});
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(microsoftClientId))
|
|
{
|
|
builder.Services.AddAuthentication().AddJwtBearer("microsoft", options =>
|
|
{
|
|
// Validate Microsoft (Entra ID / personal account) ID tokens as bearer tokens.
|
|
// "common" authority + ValidateIssuer=false: multi-tenant issuer varies per tenant id.
|
|
options.Authority = "https://login.microsoftonline.com/common/v2.0";
|
|
options.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuer = false,
|
|
ValidateAudience = true,
|
|
ValidAudience = microsoftClientId,
|
|
ValidateLifetime = true,
|
|
};
|
|
});
|
|
}
|
|
|
|
builder.Services.AddAuthorization(options =>
|
|
{
|
|
if (requireAuth)
|
|
{
|
|
options.FallbackPolicy = new AuthorizationPolicyBuilder()
|
|
.RequireAuthenticatedUser()
|
|
.Build();
|
|
}
|
|
});
|
|
|
|
builder.Services.AddRateLimiter(options =>
|
|
{
|
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
|
|
options.AddPolicy("auth-login", context =>
|
|
RateLimitPartition.GetFixedWindowLimiter(
|
|
partitionKey: $"login:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
|
|
factory: _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 10,
|
|
Window = TimeSpan.FromMinutes(5),
|
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
|
QueueLimit = 0,
|
|
}));
|
|
|
|
options.AddPolicy("auth-email", context =>
|
|
RateLimitPartition.GetFixedWindowLimiter(
|
|
partitionKey: $"email:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
|
|
factory: _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 5,
|
|
Window = TimeSpan.FromMinutes(15),
|
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
|
QueueLimit = 0,
|
|
}));
|
|
|
|
// Brute-forcing a 6-digit TOTP code (1e6 space) is far more feasible than a password, so
|
|
// this gets a tighter window than auth-login.
|
|
options.AddPolicy("auth-2fa-challenge", context =>
|
|
RateLimitPartition.GetFixedWindowLimiter(
|
|
partitionKey: $"2fa:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
|
|
factory: _ => new FixedWindowRateLimiterOptions
|
|
{
|
|
PermitLimit = 5,
|
|
Window = TimeSpan.FromMinutes(5),
|
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
|
QueueLimit = 0,
|
|
}));
|
|
});
|
|
|
|
var app = builder.Build();
|
|
|
|
if (ephemeralJwtKey)
|
|
{
|
|
app.Logger.LogWarning("Auth:JwtKey was not configured. Generated an ephemeral key; local login tokens will be invalid after restart.");
|
|
}
|
|
|
|
var enableHttpsRedirect = app.Configuration.GetValue("HttpsRedirection:Enabled", false);
|
|
var enableHsts = app.Configuration.GetValue("HttpsRedirection:Hsts", false);
|
|
if (enableHsts) app.UseHsts();
|
|
if (enableHttpsRedirect) app.UseHttpsRedirection();
|
|
|
|
// Structured request logging for easy diagnosis.
|
|
app.Use(async (ctx, next) =>
|
|
{
|
|
var sw = Stopwatch.StartNew();
|
|
try
|
|
{
|
|
await next();
|
|
sw.Stop();
|
|
|
|
var sub = ctx.User?.Claims?.FirstOrDefault(c => c.Type is "sub" or "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
|
|
app.Logger.LogInformation(
|
|
"HTTP {Method} {Path} {StatusCode} {ElapsedMs}ms trace={TraceId} sub={Sub}",
|
|
ctx.Request.Method,
|
|
ctx.Request.Path.Value ?? "",
|
|
ctx.Response.StatusCode,
|
|
sw.ElapsedMilliseconds,
|
|
ctx.TraceIdentifier,
|
|
sub ?? ""
|
|
);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
sw.Stop();
|
|
app.Logger.LogError(
|
|
ex,
|
|
"HTTP {Method} {Path} 500 {ElapsedMs}ms trace={TraceId}",
|
|
ctx.Request.Method,
|
|
ctx.Request.Path.Value ?? "",
|
|
sw.ElapsedMilliseconds,
|
|
ctx.TraceIdentifier
|
|
);
|
|
throw;
|
|
}
|
|
});
|
|
|
|
await app.InitializeJobTrackerAsync();
|
|
|
|
app.UseCors("AllowReact");
|
|
app.UseRateLimiter();
|
|
|
|
app.Use(async (ctx, next) =>
|
|
{
|
|
if (HttpMethods.IsGet(ctx.Request.Method) || HttpMethods.IsHead(ctx.Request.Method) || HttpMethods.IsOptions(ctx.Request.Method) || HttpMethods.IsTrace(ctx.Request.Method))
|
|
{
|
|
await next();
|
|
return;
|
|
}
|
|
|
|
if (!ctx.Request.Cookies.ContainsKey(AuthSessionOptions.SessionCookieName))
|
|
{
|
|
await next();
|
|
return;
|
|
}
|
|
|
|
if (ctx.Request.Path.StartsWithSegments("/api/auth/login")
|
|
|| ctx.Request.Path.StartsWithSegments("/api/auth/register")
|
|
|| ctx.Request.Path.StartsWithSegments("/api/auth/google/exchange")
|
|
|| ctx.Request.Path.StartsWithSegments("/api/auth/request-password-reset")
|
|
|| ctx.Request.Path.StartsWithSegments("/api/auth/reset-password")
|
|
|| ctx.Request.Path.StartsWithSegments("/api/auth/csrf"))
|
|
{
|
|
await next();
|
|
return;
|
|
}
|
|
|
|
var csrfCookie = ctx.Request.Cookies[AuthSessionOptions.CsrfCookieName];
|
|
var csrfHeader = ctx.Request.Headers[AuthSessionOptions.CsrfHeaderName].ToString();
|
|
if (string.IsNullOrWhiteSpace(csrfCookie) || string.IsNullOrWhiteSpace(csrfHeader) || !string.Equals(csrfCookie, csrfHeader, StringComparison.Ordinal))
|
|
{
|
|
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
await ctx.Response.WriteAsync("CSRF validation failed.");
|
|
return;
|
|
}
|
|
|
|
await next();
|
|
});
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.MapControllers();
|
|
|
|
// API schema for tooling/docs. Dev-only: not exposed in production deployments.
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.MapOpenApi().AllowAnonymous();
|
|
}
|
|
|
|
app.Run();
|