From fa9249930701ffc4a9d2b6901578d04a08696fb2 Mon Sep 17 00:00:00 2001 From: yt314 Date: Thu, 22 Oct 2026 11:04:53 +0000 Subject: [PATCH] refactor(api): move startup setup out of Program.cs Program.cs had grown to 313 lines, most of it service registrations. Move them into extension methods under Startup/, one file per concern: - ApiServices: the tenant provider and the API-layer services - AuthSetup: JWT authentication, the fallback policy, auth rate limiting - DatabaseInitialization: migrations, role seeding, the admin user - RecurringJobs: the Hangfire recurring jobs CORS and the middleware pipeline stay in Program.cs, where their order and the comments explaining it are read together. Configuration is still read at the same point in startup. Program.cs is now 105 lines. Co-Authored-By: Claude --- backend/Talmidon.Api/Program.cs | 222 +----------------- backend/Talmidon.Api/Startup/ApiServices.cs | 76 ++++++ backend/Talmidon.Api/Startup/AuthSetup.cs | 68 ++++++ .../Startup/DatabaseInitialization.cs | 73 ++++++ backend/Talmidon.Api/Startup/RecurringJobs.cs | 45 ++++ 5 files changed, 269 insertions(+), 215 deletions(-) create mode 100644 backend/Talmidon.Api/Startup/ApiServices.cs create mode 100644 backend/Talmidon.Api/Startup/AuthSetup.cs create mode 100644 backend/Talmidon.Api/Startup/DatabaseInitialization.cs create mode 100644 backend/Talmidon.Api/Startup/RecurringJobs.cs diff --git a/backend/Talmidon.Api/Program.cs b/backend/Talmidon.Api/Program.cs index 5ebd4a5..1a7702b 100644 --- a/backend/Talmidon.Api/Program.cs +++ b/backend/Talmidon.Api/Program.cs @@ -1,30 +1,8 @@ -using System.Text; -using System.Threading.RateLimiting; using Hangfire; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.RateLimiting; -using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; using Talmidon.Api.Diagnostics; -using Talmidon.Api.Multitenancy; +using Talmidon.Api.Startup; using Talmidon.Infrastructure; -using Talmidon.Infrastructure.Auth; -using Talmidon.Infrastructure.BackgroundJobs; -using Talmidon.Infrastructure.Data; -using Talmidon.Infrastructure.Identity; -using Talmidon.Api.Services; -using Talmidon.Api.Services.Auth; -using Talmidon.Api.Services.Lessons; -using Talmidon.Api.Services.Messages; -using Talmidon.Api.Services.Parents; -using Talmidon.Api.Services.Payments; -using Talmidon.Api.Services.Public; -using Talmidon.Api.Services.Students; -using Talmidon.Api.Services.Teachers; -using Talmidon.Infrastructure.Multitenancy; var builder = WebApplication.CreateBuilder(args); @@ -34,107 +12,10 @@ // שכבת התשתית: DbContext (PostgreSQL), Identity, טוקנים, מיילים builder.Services.AddInfrastructure(builder.Configuration); -// ספק דייר אמיתי מתוך טוקן ה-JWT (מחליף את NullCurrentTenant) -builder.Services.AddHttpContextAccessor(); -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.AddScoped(); -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.AddScoped(); - -// הספרייה הציבורית והפניות ממנה -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.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// אימות JWT -var jwt = builder.Configuration.GetSection("Jwt"); -var secret = jwt["SecretKey"] - ?? throw new InvalidOperationException("Jwt:SecretKey is not configured."); -if (Encoding.UTF8.GetByteCount(secret) < 32) - throw new InvalidOperationException("Jwt:SecretKey must be at least 32 bytes (256 bits) for HMAC-SHA256."); - -var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)); - -builder.Services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = jwt["Issuer"], - ValidateAudience = true, - ValidAudience = jwt["Audience"], - ValidateIssuerSigningKey = true, - IssuerSigningKey = signingKey, - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1) - }; - }); - -// כל נקודת קצה דורשת אימות כברירת מחדל; נקודות ציבוריות מסומנות [AllowAnonymous] -builder.Services.AddAuthorization(options => - options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()); - -// הגבלת קצב לנקודות האימות (לפי כתובת IP) — ניתנת לכיוונון בלי קומפילציה מחדש; -// גם מאפשרת להרחיב את המכסה בבדיקות אינטגרציה, שבהן כל הבקשות חולקות "כתובת" מזוהה אחת. -builder.Services.AddRateLimiter(options => -{ - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - var permitLimit = builder.Configuration.GetValue("RateLimiting:Auth:PermitLimit", 10); - var windowMinutes = builder.Configuration.GetValue("RateLimiting:Auth:WindowMinutes", 1); - options.AddPolicy("auth", httpContext => - RateLimitPartition.GetFixedWindowLimiter( - partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", - factory: _ => new FixedWindowRateLimiterOptions - { - PermitLimit = permitLimit, - Window = TimeSpan.FromMinutes(windowMinutes), - QueueLimit = 0 - })); -}); +// שירותי ה-API, אימות JWT והגבלת קצב לנקודות האימות +builder.Services.AddApiServices(); +builder.Services.AddJwtAuthentication(builder.Configuration); +builder.Services.AddAuthRateLimiting(builder.Configuration); // CORS לאפליקציית ה-Angular const string CorsPolicy = "TalmidonClient"; @@ -175,9 +56,7 @@ // במקום להסיק אותה משגיאת CORS בדפדפן. app.Logger.LogInformation("CORS allowed origins: {Origins}", string.Join(", ", allowedOrigins)); -await MigrateDatabaseAsync(app); -await SeedRolesAsync(app); -await SeedAdminUserAsync(app); +await app.InitializeDatabaseAsync(); if (app.Environment.IsDevelopment()) { @@ -219,95 +98,8 @@ app.UseAuthentication(); app.UseAuthorization(); -// המשימות רשומות בכל סביבה: השיעורים החוזרים והתזכורות הם חלק מהמוצר, ובלעדיהן -// המורה צריכה ליצור כל שיעור ביד. -// לוח הבקרה של Hangfire נשאר לפיתוח בלבד; הוא מאמת דרך Cookie ולא מכיר את ה-JWT. -// -// ה-API הסטטי RecurringJob שלמטה קורא מ-JobStorage.Current, ובלעדיו העלייה נופלת -// ב-"Current JobStorage instance has not been initialized yet". השרת עצמו כבר רשום -// ב-DI דרך AddHangfireServer ורץ כשירות רקע, ולכן נותר רק להצביע על אותו אחסון — -// במקום UseHangfireServer, שהוא מיושן ומרים שרת שני. -JobStorage.Current = app.Services.GetRequiredService(); - -RecurringJob.AddOrUpdate( - "monthly-payment-reminders", - job => job.RunForAllTenantsAsync(), - Cron.Monthly(), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); - -RecurringJob.AddOrUpdate( - "lesson-series-generation", - job => job.RunForAllTenantsAsync(), - Cron.Daily(), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); - -RecurringJob.AddOrUpdate( - "error-log-cleanup", - job => job.RunAsync(), - Cron.Daily(), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); - -RecurringJob.AddOrUpdate( - "lesson-reminders", - job => job.RunForAllTenantsAsync(), - Cron.Hourly(), - new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); +app.ScheduleRecurringJobs(); app.MapControllers(); app.Run(); - -/// מחילה מיגרציות ממתינות באתחול — כדי שפריסה (deploy) תהיה "git pull + docker compose up" בלי צעד ידני נפרד. -static async Task MigrateDatabaseAsync(WebApplication app) -{ - using var scope = app.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.MigrateAsync(); -} - -// זריעת התפקידים (Teacher/Parent/Student/Admin) אם חסרים -static async Task SeedRolesAsync(WebApplication app) -{ - using var scope = app.Services.CreateScope(); - var roleManager = scope.ServiceProvider.GetRequiredService>(); - foreach (var role in Roles.All) - { - if (!await roleManager.RoleExistsAsync(role)) - await roleManager.CreateAsync(new IdentityRole(role)); - } -} - -/// -/// זורעת משתמש-על יחיד לתחזוקת הפלטפורמה, אם מוגדר ב-Admin:Email/Admin:Password. בניגוד למורה/הורה/תלמיד, -/// אין הרשמה עצמית או הזמנה למנהל — זו הדרך היחידה שבה חשבון כזה נוצר, ורק אם הוגדר במפורש (כלומר -/// בפרודקשן חובה להגדיר את שני הערכים ב-secrets/סביבת ההרצה, אחרת לא ייווצר אף חשבון מנהל). -/// -static async Task SeedAdminUserAsync(WebApplication app) -{ - var email = app.Configuration["Admin:Email"] - ?? Environment.GetEnvironmentVariable("ADMIN_EMAIL"); - var password = app.Configuration["Admin:Password"] - ?? Environment.GetEnvironmentVariable("ADMIN_PASSWORD"); - if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) - return; - - using var scope = app.Services.CreateScope(); - var userManager = scope.ServiceProvider.GetRequiredService>(); - if (await userManager.FindByEmailAsync(email) is not null) - return; - - var admin = new ApplicationUser { UserName = email, Email = email, DisplayName = "מנהל מערכת", EmailConfirmed = true }; - var createResult = await userManager.CreateAsync(admin, password); - if (!createResult.Succeeded) - { - var errors = string.Join("; ", createResult.Errors.Select(error => error.Description)); - throw new InvalidOperationException($"Admin user could not be created: {errors}"); - } - - var roleResult = await userManager.AddToRoleAsync(admin, Roles.Admin); - if (!roleResult.Succeeded) - { - var errors = string.Join("; ", roleResult.Errors.Select(error => error.Description)); - throw new InvalidOperationException($"Admin role could not be assigned: {errors}"); - } -} diff --git a/backend/Talmidon.Api/Startup/ApiServices.cs b/backend/Talmidon.Api/Startup/ApiServices.cs new file mode 100644 index 0000000..3be040d --- /dev/null +++ b/backend/Talmidon.Api/Startup/ApiServices.cs @@ -0,0 +1,76 @@ +using Talmidon.Api.Multitenancy; +using Talmidon.Api.Services; +using Talmidon.Api.Services.Auth; +using Talmidon.Api.Services.Lessons; +using Talmidon.Api.Services.Messages; +using Talmidon.Api.Services.Parents; +using Talmidon.Api.Services.Payments; +using Talmidon.Api.Services.Public; +using Talmidon.Api.Services.Students; +using Talmidon.Api.Services.Teachers; +using Talmidon.Infrastructure.Multitenancy; + +namespace Talmidon.Api.Startup; + +public static class ApiServices +{ + /// שירותי שכבת ה-API: הדייר הנוכחי והשירותים שהבקרים נשענים עליהם. + public static IServiceCollection AddApiServices(this IServiceCollection services) + { + // ספק דייר אמיתי מתוך טוקן ה-JWT (מחליף את NullCurrentTenant) + services.AddHttpContextAccessor(); + services.AddScoped(); + + // הודעות למשפחה של תלמיד/ה — משותף לבקרי השיעורים והסדרות + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // חשבונות: הרשמה, מיילים, אסימונים + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // היומן: קריאות, פעולות המורה, ובקשות מהמשפחה + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // תשלומים: קריאות, ורישום/ביטול תשלום + services.AddScoped(); + services.AddScoped(); + + // תלמידים + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + // הספרייה הציבורית והפניות ממנה + services.AddScoped(); + services.AddScoped(); + + // ניהול: חשבונות מורות והצעות תחומי לימוד + services.AddScoped(); + services.AddScoped(); + + // הפרופיל ושעות הזמינות של המורה + services.AddScoped(); + services.AddScoped(); + + // דוחות + services.AddScoped(); + services.AddScoped(); + + // שיחות בין המורה למשפחה + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/backend/Talmidon.Api/Startup/AuthSetup.cs b/backend/Talmidon.Api/Startup/AuthSetup.cs new file mode 100644 index 0000000..83908b7 --- /dev/null +++ b/backend/Talmidon.Api/Startup/AuthSetup.cs @@ -0,0 +1,68 @@ +using System.Text; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.IdentityModel.Tokens; + +namespace Talmidon.Api.Startup; + +public static class AuthSetup +{ + /// אימות JWT, וכל נקודת קצה דורשת אימות כברירת מחדל; נקודות ציבוריות מסומנות [AllowAnonymous]. + public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration) + { + var jwt = configuration.GetSection("Jwt"); + var secret = jwt["SecretKey"] + ?? throw new InvalidOperationException("Jwt:SecretKey is not configured."); + if (Encoding.UTF8.GetByteCount(secret) < 32) + throw new InvalidOperationException("Jwt:SecretKey must be at least 32 bytes (256 bits) for HMAC-SHA256."); + + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)); + + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwt["Issuer"], + ValidateAudience = true, + ValidAudience = jwt["Audience"], + ValidateIssuerSigningKey = true, + IssuerSigningKey = signingKey, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1) + }; + }); + + services.AddAuthorization(options => + options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()); + + return services; + } + + /// + /// הגבלת קצב לנקודות האימות (לפי כתובת IP) — ניתנת לכיוונון בלי קומפילציה מחדש; + /// גם מאפשרת להרחיב את המכסה בבדיקות אינטגרציה, שבהן כל הבקשות חולקות "כתובת" מזוהה אחת. + /// + public static IServiceCollection AddAuthRateLimiting(this IServiceCollection services, IConfiguration configuration) + { + return services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + var permitLimit = configuration.GetValue("RateLimiting:Auth:PermitLimit", 10); + var windowMinutes = configuration.GetValue("RateLimiting:Auth:WindowMinutes", 1); + options.AddPolicy("auth", httpContext => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = permitLimit, + Window = TimeSpan.FromMinutes(windowMinutes), + QueueLimit = 0 + })); + }); + } +} diff --git a/backend/Talmidon.Api/Startup/DatabaseInitialization.cs b/backend/Talmidon.Api/Startup/DatabaseInitialization.cs new file mode 100644 index 0000000..e5be7a6 --- /dev/null +++ b/backend/Talmidon.Api/Startup/DatabaseInitialization.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Talmidon.Infrastructure.Auth; +using Talmidon.Infrastructure.Data; +using Talmidon.Infrastructure.Identity; + +namespace Talmidon.Api.Startup; + +public static class DatabaseInitialization +{ + /// מיגרציות, תפקידים ומשתמש-העל — מה שצריך להיות במסד לפני שהאפליקציה מקבלת בקשות. + public static async Task InitializeDatabaseAsync(this WebApplication app) + { + await MigrateDatabaseAsync(app); + await SeedRolesAsync(app); + await SeedAdminUserAsync(app); + } + + /// מחילה מיגרציות ממתינות באתחול — כדי שפריסה (deploy) תהיה "git pull + docker compose up" בלי צעד ידני נפרד. + private static async Task MigrateDatabaseAsync(WebApplication app) + { + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + } + + // זריעת התפקידים (Teacher/Parent/Student/Admin) אם חסרים + private static async Task SeedRolesAsync(WebApplication app) + { + using var scope = app.Services.CreateScope(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + foreach (var role in Roles.All) + { + if (!await roleManager.RoleExistsAsync(role)) + await roleManager.CreateAsync(new IdentityRole(role)); + } + } + + /// + /// זורעת משתמש-על יחיד לתחזוקת הפלטפורמה, אם מוגדר ב-Admin:Email/Admin:Password. בניגוד למורה/הורה/תלמיד, + /// אין הרשמה עצמית או הזמנה למנהל — זו הדרך היחידה שבה חשבון כזה נוצר, ורק אם הוגדר במפורש (כלומר + /// בפרודקשן חובה להגדיר את שני הערכים ב-secrets/סביבת ההרצה, אחרת לא ייווצר אף חשבון מנהל). + /// + private static async Task SeedAdminUserAsync(WebApplication app) + { + var email = app.Configuration["Admin:Email"] + ?? Environment.GetEnvironmentVariable("ADMIN_EMAIL"); + var password = app.Configuration["Admin:Password"] + ?? Environment.GetEnvironmentVariable("ADMIN_PASSWORD"); + if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) + return; + + using var scope = app.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + if (await userManager.FindByEmailAsync(email) is not null) + return; + + var admin = new ApplicationUser { UserName = email, Email = email, DisplayName = "מנהל מערכת", EmailConfirmed = true }; + var createResult = await userManager.CreateAsync(admin, password); + if (!createResult.Succeeded) + { + var errors = string.Join("; ", createResult.Errors.Select(error => error.Description)); + throw new InvalidOperationException($"Admin user could not be created: {errors}"); + } + + var roleResult = await userManager.AddToRoleAsync(admin, Roles.Admin); + if (!roleResult.Succeeded) + { + var errors = string.Join("; ", roleResult.Errors.Select(error => error.Description)); + throw new InvalidOperationException($"Admin role could not be assigned: {errors}"); + } + } +} diff --git a/backend/Talmidon.Api/Startup/RecurringJobs.cs b/backend/Talmidon.Api/Startup/RecurringJobs.cs new file mode 100644 index 0000000..57f1c2b --- /dev/null +++ b/backend/Talmidon.Api/Startup/RecurringJobs.cs @@ -0,0 +1,45 @@ +using Hangfire; +using Talmidon.Infrastructure.BackgroundJobs; + +namespace Talmidon.Api.Startup; + +public static class RecurringJobs +{ + /// + /// המשימות רשומות בכל סביבה: השיעורים החוזרים והתזכורות הם חלק מהמוצר, ובלעדיהן + /// המורה צריכה ליצור כל שיעור ביד. + /// לוח הבקרה של Hangfire נשאר לפיתוח בלבד; הוא מאמת דרך Cookie ולא מכיר את ה-JWT. + /// + public static void ScheduleRecurringJobs(this WebApplication app) + { + // ה-API הסטטי RecurringJob שלמטה קורא מ-JobStorage.Current, ובלעדיו העלייה נופלת + // ב-"Current JobStorage instance has not been initialized yet". השרת עצמו כבר רשום + // ב-DI דרך AddHangfireServer ורץ כשירות רקע, ולכן נותר רק להצביע על אותו אחסון — + // במקום UseHangfireServer, שהוא מיושן ומרים שרת שני. + JobStorage.Current = app.Services.GetRequiredService(); + + RecurringJob.AddOrUpdate( + "monthly-payment-reminders", + job => job.RunForAllTenantsAsync(), + Cron.Monthly(), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + + RecurringJob.AddOrUpdate( + "lesson-series-generation", + job => job.RunForAllTenantsAsync(), + Cron.Daily(), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + + RecurringJob.AddOrUpdate( + "error-log-cleanup", + job => job.RunAsync(), + Cron.Daily(), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + + RecurringJob.AddOrUpdate( + "lesson-reminders", + job => job.RunForAllTenantsAsync(), + Cron.Hourly(), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + } +}