diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs index 76918f0..1cfd11c 100644 --- a/src/NexusReader.Web.New/Program.cs +++ b/src/NexusReader.Web.New/Program.cs @@ -1,208 +1,84 @@ -using NexusReader.Web.Components; -using NexusReader.Application; -using NexusReader.Infrastructure; -using NexusReader.Application.Abstractions.Services; -using NexusReader.Application.Queries.User; -using MediatR; -using NexusReader.Web.Client.Services; -using NexusReader.UI.Shared.Services; -using NexusReader.Domain.Entities; -using NexusReader.Data.Persistence; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; -using Microsoft.AspNetCore.Authorization; -using NexusReader.Infrastructure.Identity; -using Microsoft.AspNetCore.Authentication; -using System.Security.Claims; -using NexusReader.Infrastructure.Services; -using Stripe; - -AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); +using NexusReader.Application.Abstractions.Services; +using NexusReader.Application.Queries.Reader; +using NexusReader.Data.Persistence; +using NexusReader.Domain.Entities; +using NexusReader.Infrastructure.Configuration; +using NexusReader.Infrastructure.RealTime; +using NexusReader.UI.Shared.Services; +using NexusReader.Web.Components; +using NexusReader.Web.New.Services; +using NexusReader.Infrastructure; +using NexusReader.Application; var builder = WebApplication.CreateBuilder(args); -// Add services to the container. -builder.Services.AddRazorComponents() - .AddInteractiveServerComponents() - .AddInteractiveWebAssemblyComponents(); +// --- Configuration --- +builder.Services.Configure(builder.Configuration.GetSection("AiSettings")); +builder.Services.Configure(builder.Configuration.GetSection("StripeSettings")); -// Enable detailed circuit errors for Server‑Side Blazor components -builder.Services.AddServerSideBlazor() - .AddCircuitOptions(options => - { - options.DetailedErrors = true; - }); -builder.Services.AddSignalR(); -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.AddHttpClient("NexusAPI", client => -{ - client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5000"); -}); -builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); - -builder.Services.AddHttpContextAccessor(); -builder.Services.AddScoped(); -builder.Services.AddCascadingAuthenticationState(); - -builder.Services.AddApplication(); +// --- Infrastructure Layer --- builder.Services.AddInfrastructure(builder.Configuration); -builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( - NexusReader.Application.DependencyInjection.Assembly, - NexusReader.Infrastructure.DependencyInjection.Assembly -)); +// --- Application Layer --- +builder.Services.AddApplication(); -// Authorization Policies -builder.Services.AddScoped(); -builder.Services.AddAuthorizationBuilder() - .AddPolicy("ProUser", policy => policy.RequireClaim("Plan", SubscriptionPlan.ProName, SubscriptionPlan.EnterpriseName)) - .AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement())); +// --- Identity & Auth --- +builder.Services.AddCascadingAuthenticationState(); +builder.Services.AddScoped(); -// Billing & Stripe -builder.Services.AddScoped(); +// Register Server-Side Native Storage (JSInterop Wrapper) +builder.Services.AddScoped(); + +// Register Server-Side Identity Service +builder.Services.AddScoped(); -// Authentication builder.Services.AddAuthentication(options => { options.DefaultScheme = IdentityConstants.ApplicationScheme; options.DefaultSignInScheme = IdentityConstants.ExternalScheme; }) - .AddGoogle(options => - { - options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "placeholder-id"; - options.ClientSecret = builder.Configuration["Authentication:Google:ClientSecret"] ?? "placeholder-secret"; - }); + .AddIdentityCookies(); -builder.Services.AddIdentityApiEndpoints() +builder.Services.AddIdentityCore(options => options.SignIn.RequireConfirmedAccount = true) .AddRoles() - .AddEntityFrameworkStores(); + .AddEntityFrameworkStores() + .AddSignInManager() + .AddDefaultTokenProviders(); -builder.Services.ConfigureApplicationCookie(options => -{ - options.LoginPath = "/account/login"; - options.LogoutPath = "/account/logout"; - options.AccessDeniedPath = "/account/access-denied"; - options.Cookie.Name = "NexusReader.Auth"; - options.Cookie.HttpOnly = true; - options.Cookie.SameSite = SameSiteMode.Lax; - options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; - options.ExpireTimeSpan = TimeSpan.FromDays(30); - options.SlidingExpiration = true; +// --- Razor Components --- +builder.Services.AddRazorComponents() + .AddInteractiveWebAssemblyComponents() + .AddInteractiveServerComponents(); - options.Events.OnRedirectToLogin = context => - { - var isApiRequest = context.Request.Path.StartsWithSegments("/api") || - context.Request.Path.StartsWithSegments("/identity") || - context.Request.Headers["Accept"].ToString().Contains("application/json"); - - if (isApiRequest) - { - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - } - else - { - context.Response.Redirect(context.RedirectUri); - } - return Task.CompletedTask; - }; -}); - -builder.Services.Configure(options => -{ - // Password settings - options.Password.RequireDigit = true; - options.Password.RequireLowercase = true; - options.Password.RequireNonAlphanumeric = true; - options.Password.RequireUppercase = true; - options.Password.RequiredLength = 8; - options.Password.RequiredUniqueChars = 1; - - // Lockout settings - options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); - options.Lockout.MaxFailedAccessAttempts = 5; - options.Lockout.AllowedForNewUsers = true; - - // User settings - options.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+"; - options.User.RequireUniqueEmail = true; -}); +// --- API Client for WASM Compatibility --- +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "https://localhost:7165/") }); var app = builder.Build(); -// Startup Validation -using (var scope = app.Services.CreateScope()) -{ - var marker = scope.ServiceProvider.GetService(); - if (marker == null) - { - throw new InvalidOperationException("CRITICAL: Infrastructure layer was not registered. Ensure AddInfrastructure() is called in Program.cs."); - } -} - -// Ensure Database is initialized and seeded +// --- Database Initialization --- using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; - var logger = services.GetRequiredService>(); - var dbContextFactory = services.GetRequiredService>(); - using var dbContext = await dbContextFactory.CreateDbContextAsync(); - - int maxRetries = 5; - int delayMs = 2000; - - for (int i = 0; i < maxRetries; i++) + try { - try + var context = services.GetRequiredService(); + if (context.Database.IsRelational()) { - if (logger.IsEnabled(LogLevel.Information)) - { - logger.LogInformation("Próba połączenia z bazą danych (próba {Attempt}/{MaxRetries})...", i + 1, maxRetries); - } - - await dbContext.Database.MigrateAsync(); - await DbInitializer.SeedAsync(services); - - if (logger.IsEnabled(LogLevel.Information)) - { - logger.LogInformation("Baza danych zainicjowana pomyślnie."); - } - break; - } - catch (Npgsql.NpgsqlException ex) when (i < maxRetries - 1) - { - if (logger.IsEnabled(LogLevel.Warning)) - { - logger.LogWarning(ex, "Błąd połączenia z bazą danych. Ponowna próba za {Delay}ms...", delayMs); - } - - await Task.Delay(delayMs); - delayMs *= 2; // Exponential backoff - } - catch (Exception ex) - { - if (logger.IsEnabled(LogLevel.Critical)) - { - logger.LogCritical(ex, "Krytyczny błąd podczas inicjalizacji bazy danych."); - } - throw; + await context.Database.MigrateAsync(); } + await DbInitializer.InitializeAsync(services); + } + catch (Exception ex) + { + var logger = services.GetRequiredService>(); + logger.LogError(ex, "An error occurred while migrating or seeding the database."); } } -// Configure the HTTP request pipeline. +// --- Middleware Pipeline --- if (app.Environment.IsDevelopment()) { app.UseWebAssemblyDebugging(); @@ -213,243 +89,23 @@ else app.UseHsts(); } -app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); -if (!app.Environment.IsDevelopment()) -{ - app.UseHttpsRedirection(); -} - +app.UseHttpsRedirection(); app.UseAntiforgery(); -app.UseAuthentication(); -app.UseAuthorization(); app.MapStaticAssets(); -app.MapHub("/synchub"); - -// API endpoint for WASM client to fetch EPUB content -app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService, ClaimsPrincipal user) => -{ - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); - var result = await epubService.GetEpubContentAsync(index, userId); - - if (result.IsSuccess) return Results.Ok(result.Value); - - var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"; - return Results.BadRequest(errorMsg); -}).RequireAuthorization(); - -var knowledgeApi = app.MapGroup("/api/knowledge").RequireAuthorization("HasAvailableTokens"); - -knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => -{ - var tenantId = user.FindFirstValue("TenantId") ?? "global"; - var result = await knowledgeService.GetKnowledgeAsync(request.Text, tenantId); - if (result.IsSuccess) return Results.Ok(result.Value); - return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); -}); - -knowledgeApi.MapPost("/graph", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => -{ - var tenantId = user.FindFirstValue("TenantId") ?? "global"; - var result = await knowledgeService.GetGraphDataAsync(request.Text, tenantId); - if (result.IsSuccess) return Results.Ok(result.Value); - return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); -}); - -knowledgeApi.MapPost("/summary", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => -{ - var tenantId = user.FindFirstValue("TenantId") ?? "global"; - var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text, tenantId); - if (result.IsSuccess) return Results.Ok(result.Value); - return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); -}); - -knowledgeApi.MapPost("/verify-groundedness", async (GroundednessRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => -{ - var tenantId = user.FindFirstValue("TenantId") ?? "global"; - var result = await knowledgeService.VerifyGroundednessAsync(request.Answer, request.Context, tenantId); - if (result.IsSuccess) return Results.Ok(result.Value); - return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); -}); - -knowledgeApi.MapDelete("/", async (IKnowledgeService knowledgeService) => -{ - var result = await knowledgeService.ClearCacheAsync(); - if (result.IsSuccess) return Results.Ok(); - - var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"; - return Results.BadRequest(errorMsg); -}); - -app.MapPost("/api/StripeWebhook", async ( - HttpContext context, - UserManager userManager, - IConfiguration configuration, - IDbContextFactory dbContextFactory) => -{ - using var dbContext = await dbContextFactory.CreateDbContextAsync(); - var json = await new StreamReader(context.Request.Body).ReadToEndAsync(); - var webhookSecret = configuration["Stripe:WebhookSecret"] ?? ""; - - try - { - var stripeEvent = EventUtility.ConstructEvent( - json, - context.Request.Headers["Stripe-Signature"], - webhookSecret - ); - - switch (stripeEvent.Type) - { - case EventTypes.CheckoutSessionCompleted: - var session = stripeEvent.Data.Object as Stripe.Checkout.Session; - await HandleSubscriptionSuccess(session?.CustomerEmail, session?.Metadata, userManager, dbContext); - break; - - case EventTypes.CustomerSubscriptionUpdated: - var subscription = stripeEvent.Data.Object as Stripe.Subscription; - await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager, dbContext); - break; - - case EventTypes.CustomerSubscriptionDeleted: - var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription; - await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager, dbContext); - break; - } - return Results.Ok(); - } - catch (StripeException e) - { - return Results.BadRequest(e.Message); - } -}); - -async Task HandleSubscriptionSuccess( - string? email, - Dictionary? metadata, - UserManager userManager, - AppDbContext dbContext) -{ - if (string.IsNullOrEmpty(email)) return; - - var user = await userManager.FindByEmailAsync(email); - if (user != null) - { - var planName = metadata?.GetValueOrDefault("Plan") ?? SubscriptionPlan.ProName; - var plan = await dbContext.SubscriptionPlans.FirstOrDefaultAsync(p => p.PlanName == planName); - - if (plan != null) - { - user.SubscriptionPlanId = plan.Id; - user.AITokenLimit = plan.AITokenLimit; - } - - await userManager.UpdateAsync(user); - } -} - -async Task HandleSubscriptionCancellation( - string? email, - UserManager userManager, - AppDbContext dbContext) -{ - if (string.IsNullOrEmpty(email)) return; - - var user = await userManager.FindByEmailAsync(email); - if (user != null) - { - var freePlan = await dbContext.SubscriptionPlans.FindAsync(SubscriptionPlan.FreeId); - user.SubscriptionPlanId = SubscriptionPlan.FreeId; - user.AITokenLimit = freePlan?.AITokenLimit ?? 5000; - await userManager.UpdateAsync(user); - } -} - -app.MapGroup("/identity").MapIdentityApi(); - -app.MapGet("/identity/login/google", (string? returnUrl) => -{ - var properties = new AuthenticationProperties - { - RedirectUri = "/identity/callback/google", - Items = { { "returnUrl", returnUrl ?? "/" } } - }; - return Results.Challenge(properties, new[] { "Google" }); -}); - -app.MapGet("/identity/callback/google", async ( - HttpContext context, - SignInManager signInManager, - UserManager userManager, - ILogger logger) => -{ - var info = await signInManager.GetExternalLoginInfoAsync(); - if (info == null) - { - logger.LogWarning("External login info from Google is null."); - return Results.Redirect("/account/login?error=ExternalLoginFailed"); - } - - var result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false); - if (result.Succeeded) - { - logger.LogInformation("User logged in via Google: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email)); - return Results.Redirect("/"); - } - - if (result.IsLockedOut) - { - logger.LogWarning("User account locked out during Google login: {Email}", info.Principal.FindFirstValue(ClaimTypes.Email)); - return Results.Redirect("/account/login?error=LockedOut"); - } - - // New user provisioning - var email = info.Principal.FindFirstValue(ClaimTypes.Email); - if (email != null) - { - var user = new NexusUser { UserName = email, Email = email, EmailConfirmed = true }; - var createResult = await userManager.CreateAsync(user); - - if (createResult.Succeeded) - { - await userManager.AddLoginAsync(user, info); - await signInManager.SignInAsync(user, isPersistent: false); - logger.LogInformation("New user provisioned via Google: {Email}", email); - return Results.Redirect("/"); - } - - // Log specific errors - foreach (var error in createResult.Errors) - { - logger.LogError("Google provisioning failed for {Email}: {Code} - {Description}", email, error.Code, error.Description); - } - - if (createResult.Errors.Any(e => e.Code == "DuplicateEmail" || e.Code == "DuplicateUserName")) - { - return Results.Redirect("/account/login?error=UserAlreadyExists"); - } - } - - logger.LogError("Google provisioning failed - unknown reason for email {Email}", email); - return Results.Redirect("/account/login?error=ProvisioningFailed"); -}); - -app.MapGet("/identity/profile", async (ClaimsPrincipal user, IMediator mediator) => -{ - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); - if (userId == null) return Results.Unauthorized(); - - var result = await mediator.Send(new GetUserProfileQuery(userId)); - if (result.IsFailed) return Results.NotFound(result.Errors.FirstOrDefault()?.Message); - - return Results.Ok(result.Value); -}).RequireAuthorization(); app.MapRazorComponents() - .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() - .AddAdditionalAssemblies(typeof(NexusReader.UI.Shared.Services.IKnowledgeGraphService).Assembly); + .AddInteractiveServerRenderMode() + .AddAdditionalAssemblies(typeof(NexusReader.UI.Shared._Imports).Assembly) + .AddAdditionalAssemblies(typeof(NexusReader.Web.Client._Imports).Assembly); + +// --- API Endpoints --- +app.MapHub("/hubs/sync"); + +app.MapGet("/api/epub/content", async (int index, string? userId, IEpubReader epubReader) => +{ + var result = await epubReader.GetEpubContentAsync(index, userId); + return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Errors); +}); app.Run(); - -public record KnowledgeRequest(string Text); -public record GroundednessRequest(string Answer, string Context);