3a906b881e
Evolve the existing readiness workflow into one persisted, user-controlled checklist rather than adding a second tracker. ApplicationChecklistItem records only completion state and user intent. Each default system item carries a stable SystemKey and an AutoSignal — the same signal /readiness already computed — and re-syncs on every read: a satisfied signal auto-completes the item, a reverted signal reopens it, and a manual tick always wins. Users can add, reorder, dismiss and delete. Readiness is refactored into a projection of the checklist (score = completion percentage, completed/missing = live items by status). Its DTO shape and the workflowSignal/reminders health view are unchanged, so no API contract breaks. The workspace's next recommended action now comes from the first pending checklist item in category priority order (preparation, submission, follow-up, interview, custom), replacing the parallel ruleset — so the overview can never recommend something already ticked off, and a user's own task can be next. The table follows the established MariaDB-safe path: the scaffolded migration is a no-op and the idempotent reconciler owns the DDL for both providers. Verified on MariaDB 11 — auto_increment PK, varchar/datetime(6)/tinyint(1) columns, both indexes inside the key limit, cascade delete, unique system key per application, and NULL system keys not colliding for custom items. 329 backend tests, 94 frontend tests, type check, production build and both Docker builds pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
526 lines
21 KiB
C#
526 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.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();
|