Program.cs
11,345 bytes
| 1 | using System.Globalization; |
|---|---|
| 2 | using System.Text; |
| 3 | using Asp.Versioning; |
| 4 | using Asp.Versioning.ApiExplorer; |
| 5 | using Microsoft.AspNetCore.Authentication.JwtBearer; |
| 6 | using Microsoft.AspNetCore.DataProtection; |
| 7 | using Microsoft.AspNetCore.Localization; |
| 8 | using Microsoft.AspNetCore.Mvc; |
| 9 | using Microsoft.Extensions.Options; |
| 10 | using Microsoft.IdentityModel.Tokens; |
| 11 | using SplitApp.Modules.Expenses.Infrastructure; |
| 12 | using SplitApp.Modules.Trips.Infrastructure; |
| 13 | using SplitApp.Shared.Messaging; |
| 14 | using SplitApp.WebApp.Application.Messaging; |
| 15 | using SplitApp.WebApp.Application.UsersService; |
| 16 | using SplitApp.WebApp.Hosting; |
| 17 | using Swashbuckle.AspNetCore.SwaggerGen; |
| 18 | |
| 19 | var builder = WebApplication.CreateBuilder(args); |
| 20 | |
| 21 | // MVC + API + view localization. ApplicationParts surface every remaining module's |
| 22 | // controllers (Trips, Expenses). The Users module now lives in SplitApp.UsersService. |
| 23 | builder.Services |
| 24 | .AddControllersWithViews(opts => |
| 25 | { |
| 26 | opts.ModelBinderProviders.Insert(0, new InvariantDecimalModelBinderProvider()); |
| 27 | }) |
| 28 | .AddApplicationPart(typeof(SplitApp.Modules.Trips.Api.Controllers.TripsController).Assembly) |
| 29 | .AddApplicationPart(typeof(SplitApp.Modules.Expenses.Api.Controllers.ExpensesController).Assembly) |
| 30 | .AddViewLocalization() |
| 31 | .AddDataAnnotationsLocalization(options => |
| 32 | { |
| 33 | options.DataAnnotationLocalizerProvider = (type, factory) => |
| 34 | factory.Create(typeof(App.Resources.Views.Shared)); |
| 35 | }); |
| 36 | |
| 37 | builder.Services.AddRazorPages(); |
| 38 | |
| 39 | // API versioning |
| 40 | builder.Services.AddApiVersioning(options => |
| 41 | { |
| 42 | options.ReportApiVersions = true; |
| 43 | options.DefaultApiVersion = new ApiVersion(1, 0); |
| 44 | options.AssumeDefaultVersionWhenUnspecified = true; |
| 45 | options.ApiVersionReader = new UrlSegmentApiVersionReader(); |
| 46 | }).AddApiExplorer(options => |
| 47 | { |
| 48 | options.GroupNameFormat = "'v'VVV"; |
| 49 | options.SubstituteApiVersionInUrl = true; |
| 50 | }); |
| 51 | |
| 52 | builder.Services.AddEndpointsApiExplorer(); |
| 53 | builder.Services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>(); |
| 54 | builder.Services.AddSwaggerGen(); |
| 55 | |
| 56 | // Per-module DI registration. Users module is no longer in-process — it runs in |
| 57 | // SplitApp.UsersService and is reached over HTTP REST + RabbitMQ RPC. |
| 58 | builder.Services.AddTripsModule(builder.Configuration); |
| 59 | builder.Services.AddExpensesModule(builder.Configuration); |
| 60 | |
| 61 | // JWT bearer auth. Same signing key/issuer/audience as the Users service — tokens |
| 62 | // issued there are validated locally here. OnMessageReceived also accepts the JWT from |
| 63 | // a `jwt` cookie so the MVC views work without a manual Authorization header. |
| 64 | builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) |
| 65 | .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => |
| 66 | { |
| 67 | options.RequireHttpsMetadata = false; |
| 68 | options.SaveToken = false; |
| 69 | options.TokenValidationParameters = new TokenValidationParameters |
| 70 | { |
| 71 | ValidateIssuer = true, |
| 72 | ValidateAudience = true, |
| 73 | ValidateLifetime = true, |
| 74 | ValidateIssuerSigningKey = true, |
| 75 | ValidIssuer = builder.Configuration["JWT:Issuer"], |
| 76 | ValidAudience = builder.Configuration["JWT:Audience"], |
| 77 | IssuerSigningKey = new SymmetricSecurityKey( |
| 78 | Encoding.UTF8.GetBytes(builder.Configuration["JWT:Key"]!)), |
| 79 | ClockSkew = TimeSpan.Zero, |
| 80 | }; |
| 81 | options.Events = new JwtBearerEvents |
| 82 | { |
| 83 | OnMessageReceived = ctx => |
| 84 | { |
| 85 | if (string.IsNullOrEmpty(ctx.Token) |
| 86 | && ctx.Request.Cookies.TryGetValue("jwt", out var cookieJwt) |
| 87 | && !string.IsNullOrEmpty(cookieJwt)) |
| 88 | { |
| 89 | ctx.Token = cookieJwt; |
| 90 | } |
| 91 | return Task.CompletedTask; |
| 92 | }, |
| 93 | }; |
| 94 | }); |
| 95 | |
| 96 | builder.Services.AddAuthorization(); |
| 97 | |
| 98 | // DataProtection keys persist to file system so antiforgery + cookie payloads survive |
| 99 | // container restarts. Docker compose mounts /app/keys as a named volume. |
| 100 | builder.Services.AddDataProtection() |
| 101 | .PersistKeysToFileSystem(new DirectoryInfo(builder.Environment.IsDevelopment() ? "./keys" : "/app/keys")) |
| 102 | .SetApplicationName("SplitApp.WebApp"); |
| 103 | |
| 104 | // Composition-root facade: aggregates the two remaining module DbContexts (Trips + Expenses). |
| 105 | // IUserLookup (from messaging) replaces UsersDbContext for hydrating cross-service navs. |
| 106 | builder.Services.AddScoped<SplitApp.WebApp.Application.Contracts.IAppUnitOfWork, |
| 107 | SplitApp.WebApp.Application.Persistence.AppUnitOfWork>(); |
| 108 | builder.Services.AddScoped<SplitApp.WebApp.Application.Persistence.CrossModuleNavigationLoader>(); |
| 109 | |
| 110 | // Phase 2 BLL services (lifted into WebApp/Application) — use IAppUnitOfWork. |
| 111 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ITripService, SplitApp.WebApp.Application.Services.TripService>(); |
| 112 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IExpenseService, SplitApp.WebApp.Application.Services.ExpenseService>(); |
| 113 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ISettlementService, SplitApp.WebApp.Application.Services.SettlementService>(); |
| 114 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IInvitationService, SplitApp.WebApp.Application.Services.InvitationService>(); |
| 115 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IPollService, SplitApp.WebApp.Application.Services.PollService>(); |
| 116 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IBudgetCategoryService, SplitApp.WebApp.Application.Services.BudgetCategoryService>(); |
| 117 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IWishlistService, SplitApp.WebApp.Application.Services.WishlistService>(); |
| 118 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ISplitPresetService, SplitApp.WebApp.Application.Services.SplitPresetService>(); |
| 119 | |
| 120 | // Admin BLL services. |
| 121 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IBudgetCategoryAdminService, SplitApp.WebApp.Application.Services.Admin.BudgetCategoryAdminService>(); |
| 122 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ICurrencyAdminService, SplitApp.WebApp.Application.Services.Admin.CurrencyAdminService>(); |
| 123 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ITripAdminService, SplitApp.WebApp.Application.Services.Admin.TripAdminService>(); |
| 124 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IExpenseAdminService, SplitApp.WebApp.Application.Services.Admin.ExpenseAdminService>(); |
| 125 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IPollAdminService, SplitApp.WebApp.Application.Services.Admin.PollAdminService>(); |
| 126 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IWishlistAdminService, SplitApp.WebApp.Application.Services.Admin.WishlistAdminService>(); |
| 127 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISettlementPlanAdminService, SplitApp.WebApp.Application.Services.Admin.SettlementPlanAdminService>(); |
| 128 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISettlementPaymentAdminService, SplitApp.WebApp.Application.Services.Admin.SettlementPaymentAdminService>(); |
| 129 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISplitPresetAdminService, SplitApp.WebApp.Application.Services.Admin.SplitPresetAdminService>(); |
| 130 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ITripParticipantAdminService, SplitApp.WebApp.Application.Services.Admin.TripParticipantAdminService>(); |
| 131 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IInvitationAdminService, SplitApp.WebApp.Application.Services.Admin.InvitationAdminService>(); |
| 132 | builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IAdminStatsService, SplitApp.WebApp.Application.Services.Admin.AdminStatsService>(); |
| 133 | |
| 134 | // Messaging: RabbitMQ for inter-service RPC + events. ServiceName "webapp" appears in |
| 135 | // queue names so each subscriber gets its own durable queue. |
| 136 | builder.Services.AddMessaging(builder.Configuration, serviceName: "webapp"); |
| 137 | builder.Services.AddIntegrationEventHandler<SplitApp.Shared.Messaging.Integration.Users.UserDeletedEvent, UserDeletedEventHandler>(); |
| 138 | |
| 139 | // Typed HttpClient for the Users service. JwtForwardingHandler propagates the inbound |
| 140 | // JWT (cookie or Authorization header) on outbound calls. |
| 141 | builder.Services.AddHttpContextAccessor(); |
| 142 | builder.Services.AddTransient<JwtForwardingHandler>(); |
| 143 | builder.Services.AddHttpClient<IUsersServiceClient, UsersServiceClient>(c => |
| 144 | { |
| 145 | c.BaseAddress = new Uri(builder.Configuration["UsersService:BaseUrl"] ?? "http://users-service:8080"); |
| 146 | c.Timeout = TimeSpan.FromSeconds(30); |
| 147 | }) |
| 148 | .AddHttpMessageHandler<JwtForwardingHandler>(); |
| 149 | |
| 150 | // Localization — EN+ET. resx files in Resources/Views/Shared.{resx,et.resx} are |
| 151 | // embedded with explicit LogicalName. |
| 152 | builder.Services.AddLocalization(options => options.ResourcesPath = ""); |
| 153 | |
| 154 | var supportedCultures = (builder.Configuration.GetSection("SupportedCultures").Get<string[]>() |
| 155 | ?? new[] { "en", "et" }) |
| 156 | .Select(c => new CultureInfo(c)).ToArray(); |
| 157 | builder.Services.Configure<RequestLocalizationOptions>(options => |
| 158 | { |
| 159 | options.SupportedCultures = supportedCultures; |
| 160 | options.SupportedUICultures = supportedCultures; |
| 161 | options.DefaultRequestCulture = new RequestCulture("en", "en"); |
| 162 | options.SetDefaultCulture("en"); |
| 163 | options.RequestCultureProviders = new List<IRequestCultureProvider> |
| 164 | { |
| 165 | new QueryStringRequestCultureProvider(), |
| 166 | new CookieRequestCultureProvider(), |
| 167 | }; |
| 168 | }); |
| 169 | |
| 170 | // CORS — same wide-open policy as phase 2/3 so external SPA frontends can call /api/v1/*. |
| 171 | builder.Services.AddCors(options => |
| 172 | { |
| 173 | options.AddPolicy("CorsAllowAll", policy => |
| 174 | { |
| 175 | policy |
| 176 | .AllowAnyOrigin() |
| 177 | .AllowAnyHeader() |
| 178 | .AllowAnyMethod() |
| 179 | .WithExposedHeaders("X-Version", "X-Version-Created-At"); |
| 180 | }); |
| 181 | }); |
| 182 | |
| 183 | var app = builder.Build(); |
| 184 | |
| 185 | // Per-module pipeline + migration hooks. Users no longer here. |
| 186 | if (!app.Environment.IsEnvironment("Testing")) |
| 187 | { |
| 188 | app.UseTripsModule(); |
| 189 | app.UseExpensesModule(); |
| 190 | |
| 191 | if (app.Configuration.GetValue<bool>("DataInitialization:SeedData")) |
| 192 | { |
| 193 | SplitApp.WebApp.Hosting.AppDataInit.SeedExampleData(app.Services); |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | if (app.Environment.IsDevelopment()) |
| 198 | { |
| 199 | app.UseDeveloperExceptionPage(); |
| 200 | } |
| 201 | else |
| 202 | { |
| 203 | app.UseExceptionHandler("/Home/Error"); |
| 204 | } |
| 205 | |
| 206 | app.UseSwagger(); |
| 207 | app.UseSwaggerUI(options => |
| 208 | { |
| 209 | var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>(); |
| 210 | foreach (var description in provider.ApiVersionDescriptions) |
| 211 | { |
| 212 | options.SwaggerEndpoint( |
| 213 | $"/swagger/{description.GroupName}/swagger.json", |
| 214 | description.GroupName.ToUpperInvariant()); |
| 215 | } |
| 216 | }); |
| 217 | |
| 218 | app.UseStaticFiles(); |
| 219 | app.UseHttpsRedirection(); |
| 220 | app.UseRouting(); |
| 221 | |
| 222 | app.UseRequestLocalization(app.Services.GetRequiredService<IOptions<RequestLocalizationOptions>>().Value); |
| 223 | |
| 224 | app.UseCors("CorsAllowAll"); |
| 225 | |
| 226 | app.UseAuthentication(); |
| 227 | app.UseAuthorization(); |
| 228 | |
| 229 | app.MapControllerRoute( |
| 230 | name: "areas", |
| 231 | pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}"); |
| 232 | app.MapControllerRoute( |
| 233 | name: "default", |
| 234 | pattern: "{controller=Home}/{action=Index}/{id?}"); |
| 235 | app.MapControllers(); |
| 236 | app.MapRazorPages(); |
| 237 | |
| 238 | app.Run(); |
| 239 | |
| 240 | public partial class Program; |
| 241 | |