a3e18e4b44
Phase 4 foundation. A CvVariant is a lens over the master CareerProfile
(section order/visibility, per-item overrides keyed by ItemKey, theme +
builder settings) — it references career data, never duplicates it. One
renderer (ThemedCvRenderer) draws every theme; a theme is pure data
(CvThemeCatalog, 8 professional themes), so adding a theme needs no renderer
change. Autosave version history + non-destructive restore, public CV via
/api/public-cv/{slug} (anonymous, noindex, filter-bypassing owner load), and
an AI-assist endpoint reusing the existing provider abstraction (suggestions
only, never auto-applied).
- Models: CvVariant/CvVariantVersion, CvVariantSettings, CvTheme + catalog
- Services: CvVariantResolver (profile+lens -> render model), ThemedCvRenderer,
CvVariantService, CareerProfileService.LoadStructuredForOwnerAsync (public)
- API: CvVariantController (/api/cv), PublicCvController (/api/public-cv)
- Migration AddCvVariants (2 self-contained tables; verified applied on the
running container), 16 tests (resolver/renderer/service), 296 backend green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
523 lines
20 KiB
C#
523 lines
20 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.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();
|