using NexusReader.Web.Components; using NexusReader.Application; using NexusReader.Infrastructure; using NexusReader.Application.Abstractions.Services; using NexusReader.Web.Client.Services; using NexusReader.UI.Shared.Services; using NexusReader.Domain.Entities; using NexusReader.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Components.Authorization; 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); var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents(); // Enable detailed circuit errors for Server‑Side Blazor components builder.Services.AddServerSideBlazor() .AddCircuitOptions(options => { options.DetailedErrors = true; }); 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.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); // Authorization Policies builder.Services.AddScoped(); builder.Services.AddAuthorizationBuilder() .AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise")) .AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement())); // Billing & Stripe 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"; }); builder.Services.AddIdentityApiEndpoints() .AddRoles() .AddEntityFrameworkStores(); builder.Services.ConfigureApplicationCookie(options => { options.LoginPath = "/account/login"; options.Cookie.HttpOnly = true; options.ExpireTimeSpan = TimeSpan.FromDays(30); options.SlidingExpiration = true; }); 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; }); var app = builder.Build(); // Ensure Database is initialized and seeded using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; var logger = services.GetRequiredService>(); var dbContext = services.GetRequiredService(); int maxRetries = 5; int delayMs = 2000; for (int i = 0; i < maxRetries; i++) { try { 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; } } } // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseWebAssemblyDebugging(); } else { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); } app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); if (!app.Environment.IsDevelopment()) { app.UseHttpsRedirection(); } app.UseAntiforgery(); app.UseAuthentication(); app.UseAuthorization(); app.MapStaticAssets(); // API endpoint for WASM client to fetch EPUB content app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) => { var result = await epubService.GetEpubContentAsync(index); 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, IKnowledgeService knowledgeService) => { var result = await knowledgeService.GetKnowledgeAsync(request.Text); 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, IKnowledgeService knowledgeService) => { var result = await knowledgeService.GetGraphDataAsync(request.Text); 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, IKnowledgeService knowledgeService) => { var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text); 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) => { 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); break; case EventTypes.CustomerSubscriptionUpdated: var subscription = stripeEvent.Data.Object as Stripe.Subscription; await HandleSubscriptionSuccess(subscription?.Metadata["CustomerEmail"], subscription?.Metadata, userManager); break; case EventTypes.CustomerSubscriptionDeleted: var deletedSubscription = stripeEvent.Data.Object as Stripe.Subscription; await HandleSubscriptionCancellation(deletedSubscription?.Metadata["CustomerEmail"], userManager); break; } return Results.Ok(); } catch (StripeException e) { return Results.BadRequest(e.Message); } }); async Task HandleSubscriptionSuccess(string? email, Dictionary? metadata, UserManager userManager) { if (string.IsNullOrEmpty(email)) return; var user = await userManager.FindByEmailAsync(email); if (user != null) { var plan = metadata?.GetValueOrDefault("Plan") ?? "Pro"; user.CurrentPlan = plan; user.AITokenLimit = plan.ToLower() switch { "pro" => 50000, "enterprise" => 500000, _ => 10000 // default for unknown or free }; await userManager.UpdateAsync(user); } } async Task HandleSubscriptionCancellation(string? email, UserManager userManager) { if (string.IsNullOrEmpty(email)) return; var user = await userManager.FindByEmailAsync(email); if (user != null) { user.CurrentPlan = "Free"; user.AITokenLimit = 5000; // Free tier limit 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) => { var info = await signInManager.GetExternalLoginInfoAsync(); if (info == null) return Results.Redirect("/account/login?error=ExternalLoginFailed"); var result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false); if (result.Succeeded) { return Results.Redirect("/"); } // New user provisioning var email = info.Principal.FindFirstValue(ClaimTypes.Email); if (email != null) { // TODO: REV-5 - Consider redirecting to Terms of Service / Onboarding before final provisioning 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); return Results.Redirect("/"); } } return Results.Redirect("/account/login?error=ProvisioningFailed"); }); app.MapGet("/identity/profile", async (ClaimsPrincipal user, UserManager userManager, AppDbContext dbContext) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (userId == null) return Results.Unauthorized(); var profile = await dbContext.Users .Where(u => u.Id == userId) .Select(u => new { u.Email, u.AITokenLimit, u.AITokensUsed, u.CurrentPlan, u.TenantId, AverageQuizScore = u.QuizResults.Any() ? (int?)u.QuizResults.Average(q => q.Percentage) ?? 0 : 0, LastReadBookTitle = u.Ebooks.OrderByDescending(e => e.LastReadDate).Select(e => e.Title).FirstOrDefault() ?? "None" }) .FirstOrDefaultAsync(); if (profile == null) return Results.NotFound(); return Results.Ok(profile); }).RequireAuthorization(); app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(NexusReader.UI.Shared.Services.IKnowledgeGraphService).Assembly); app.Run(); public record KnowledgeRequest(string Text);