Files

443 lines
16 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<ICvPdfExporter, PlaywrightCvPdfExporter>();
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();
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.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.
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);
});
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
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;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<JobTrackerContext>()
.AddSignInManager();
builder.Services.AddScoped<ITokenService, TokenService>();
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 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))
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 ?? "";
return iss is "accounts.google.com" or "https://accounts.google.com"
? "google"
: "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 = context =>
{
var userId = LocalAuthIdentity.GetRequiredUserId(context.Principal);
if (userId is not null)
{
return Task.CompletedTask;
}
context.Fail("Local tokens must include a subject/nameidentifier claim.");
return Task.CompletedTask;
}
};
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 =>
{
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,
}));
});
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();
app.Run();