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.HttpOverrides; 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); var externalOrigin = ExternalOrigin.FromConfiguration(builder.Configuration, builder.Environment.IsProduction()); // Avoid Windows EventLog provider issues in local dev environments. builder.Logging.ClearProviders(); if (builder.Environment.IsProduction()) builder.Logging.AddJsonConsole(); else { builder.Logging.AddSimpleConsole(); builder.Logging.AddDebug(); } builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(externalOrigin); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Add DbContext builder.Services.AddDbContext((sp, options) => { var cfg = sp.GetRequiredService(); var paths = sp.GetRequiredService(); 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() ?? Array.Empty(); if (origins.Length == 0) { origins = new[] { "http://localhost:3000" }; } if (origins.Any(x => x.Trim() == "*")) throw new InvalidOperationException("Cors:Origins cannot contain wildcard when credentialed requests are enabled."); policy.WithOrigins(origins.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray()) .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials(); }); }); // Add controllers builder.Services.AddControllers(); builder.Services.AddProblemDetails(options => { options.CustomizeProblemDetails = context => context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier; }); 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(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); 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); } }).AddHttpMessageHandler(); builder.Services.AddMemoryCache(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph + IMAP today; manual next). builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddIdentityCore(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() .AddEntityFrameworkStores() .AddDefaultTokenProviders() .AddSignInManager(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); var translationProvider = (builder.Configuration["Translation:Provider"] ?? "none").Trim().ToLowerInvariant(); builder.Services.AddSingleton(sp => { return translationProvider switch { "libretranslate" => new LibreTranslateService(sp.GetRequiredService(), sp.GetRequiredService()), _ => new NoOpTranslationService() }; }); builder.Services.AddScoped(); var requireAuth = builder.Configuration.GetValue("Auth:Require", false); var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim(); var microsoftClientId = (builder.Configuration["Auth:MicrosoftClientId"] ?? "").Trim(); var microsoftTenantWasDefaulted = !string.IsNullOrWhiteSpace(microsoftClientId) && string.IsNullOrWhiteSpace(builder.Configuration["Auth:MicrosoftTenant"]) && !builder.Environment.IsProduction(); if (!string.IsNullOrWhiteSpace(microsoftClientId)) MicrosoftTenantPolicy.Parse(builder.Configuration["Auth:MicrosoftTenant"], builder.Environment.IsProduction()); 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 { ["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)) 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"; 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(); if (!await LocalSessionValidator.IsValidAsync( db, context.Principal, DateTimeOffset.UtcNow, builder.Configuration.GetValue("Auth:RequireEmailVerification", false))) { 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, }; }); } builder.Services.AddAuthorization(options => { options.AddPolicy(ProEntitlement.Policy, policy => policy.AddRequirements(new ProEntitlementRequirement())); if (requireAuth) { options.FallbackPolicy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build(); } }); builder.Services.AddScoped(); builder.Services.AddSingleton(); 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, })); options.AddPolicy("public-pdf", context => RateLimitPartition.GetFixedWindowLimiter( partitionKey: $"public-pdf:{context.Request.RouteValues["slug"]?.ToString() ?? "unknown"}", factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 3, Window = TimeSpan.FromMinutes(1), 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 (app.Configuration.GetValue("Proxy:TrustForwardedHeaders", false)) { app.UseForwardedHeaders(ForwardedProxyConfiguration.Build(app.Configuration)); } if (microsoftTenantWasDefaulted) { app.Logger.LogWarning("Auth:MicrosoftTenant was not configured; Development/Test defaults to common. Production requires an explicit value."); } if (app.Environment.IsProduction()) { app.Use(async (ctx, next) => { if (!externalOrigin.AllowsRequest(ctx.Request.Host, ctx.Request.Path)) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; await ctx.Response.WriteAsync("Unknown host."); return; } await next(); }); } if (enableHsts) app.UseHsts(); if (enableHttpsRedirect) app.UseHttpsRedirection(); app.UseExceptionHandler(); // 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(); await using (var attachmentScope = app.Services.CreateAsyncScope()) { var result = await attachmentScope.ServiceProvider.GetRequiredService() .ReconcileAsync(attachmentScope.ServiceProvider.GetRequiredService(), CancellationToken.None); if (result.Missing > 0 || result.UnknownOrphans > 0 || result.UnsafePaths > 0 || result.Failures > 0) { app.Logger.LogWarning( "Attachment reconciliation completed with missing={Missing}, unknownOrphans={UnknownOrphans}, unsafePaths={UnsafePaths}, failures={Failures}; unknown files were not removed.", result.Missing, result.UnknownOrphans, result.UnsafePaths, result.Failures); } else if (result.Promoted > 0 || result.Restored > 0 || result.Purged > 0) { app.Logger.LogInformation( "Attachment reconciliation recovered promoted={Promoted}, restored={Restored}, purged={Purged} files.", result.Promoted, result.Restored, result.Purged); } } 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/logout") || 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(); // Liveness probe for the container health check. Deliberately cheap and anonymous: it answers "is // this process up and serving?" and nothing else. It does NOT touch the database — a health check // that queried MariaDB would restart a perfectly healthy backend whenever the database blipped, and // would also be a free unauthenticated way to probe database availability. // docs/production-readiness-review.md. // Version comes from configuration (App:Version), which is how docker-compose supplies it as // App__Version. Reading the APP_VERSION environment variable directly meant /health always // reported "unknown" in a container. Falls back to the assembly version for local development. app.MapGet("/health", (IConfiguration cfg) => Results.Ok(new { status = "ok", version = BuildMetadata.ResolveVersion(cfg), })).AllowAnonymous(); // API schema for tooling/docs. Dev-only: not exposed in production deployments. if (app.Environment.IsDevelopment()) { app.MapOpenApi().AllowAnonymous(); } app.Run();