a7cecce13d
Phase 5.3. Three read-only reads that answer "how suitable is this job", "how
does my experience match", "what am I missing", "what happened previously".
Timeline (GET /{id}/timeline) is an interpretation layer over JobEvent, which
stays the source of historical truth. Each row gains a readable summary, a
category and a milestone flag; events group by day. Milestones are returned
unfiltered, because narrowing the detail must not hide what actually happened.
Job analysis (GET /{id}/analysis) extracts role, company, location, employment
type, seniority, salary, technologies, skills, responsibilities and keywords
from the advert, reusing the existing SkillTagger so the vocabulary matches the
job importer. It also reports what the advert does NOT say, which is usually the
more useful half.
Career matching (GET /{id}/match) feeds the master CareerProfile into the same
JobCvMatchService the CV builder uses, so one application scores identically
whichever surface asks. It returns the score, matched and missing skills, and
which experience and project entries are the evidence for each match.
All three are deterministic and own no data — no new table, no new column, and
nothing writes to the CareerProfile, a CvVariant, or the JobApplication. The AI
narrative stays where it already was, in AiWorkspaceService's job-analysis and
career-match modules, generated only when the user asks and versioned by the
append-only AiInteraction history. Opening a section costs nothing and changes
nothing.
Frontend adds Timeline, Analysis and Match sections to the workspace, sharing
one loader so loading, empty and error states are consistent. The deterministic
answer renders first, with the AI panel below it.
345 backend tests, 104 frontend tests, type check, production build all pass
locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
528 lines
21 KiB
C#
528 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.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();
|