using NexusReader.Web.Components; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Components; using NexusReader.Application; using NexusReader.Infrastructure; using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Queries.User; using NexusReader.Application.Commands.Library; using NexusReader.Application.Queries.Library; 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.EntityFrameworkCore; using Microsoft.AspNetCore.Authorization; using NexusReader.Infrastructure.Identity; using Microsoft.AspNetCore.Authentication; using System.Security.Claims; using NexusReader.Infrastructure.Services; using Stripe; using Microsoft.Extensions.AI; using NexusReader.Application.Abstractions.Persistence; using NexusReader.Application.Abstractions.Messaging; 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.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", (sp, client) => { var configuration = sp.GetRequiredService(); var apiBaseUrl = configuration["ApiBaseUrl"]; if (!string.IsNullOrEmpty(apiBaseUrl)) { client.BaseAddress = new Uri(apiBaseUrl); } else { // For local development/Interactive Server, we use the current base address var nav = sp.GetRequiredService(); client.BaseAddress = new Uri(nav.BaseUri); } }); builder.Services.AddScoped(sp => sp.GetRequiredService().CreateClient("NexusAPI")); builder.Services.AddScoped(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( NexusReader.Application.DependencyInjection.Assembly, NexusReader.Infrastructure.DependencyInjection.Assembly )); // 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())); // 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.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; 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; }); 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 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 { 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.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); app.MapStaticAssets(); app.MapHub("/synchub"); // API endpoint for WASM client to fetch EPUB content app.MapGet("/api/epub/{ebookId:guid}/{index:int}", async (Guid ebookId, int index, IEpubReader epubService, ClaimsPrincipal user) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var result = await epubService.GetEpubContentAsync(ebookId, 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") .DisableAntiforgery(); knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; var result = await knowledgeService.GetKnowledgeAsync(request.Text, tenantId, request.EbookId); 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, request.EbookId); 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, request.EbookId); if (result.IsSuccess) return Results.Ok(result.Value); return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error"); }); knowledgeApi.MapPost("/map", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) => { var tenantId = user.FindFirstValue("TenantId") ?? "global"; var result = await knowledgeService.GetKnowledgeMapAsync(request.Text, tenantId, request.EbookId); 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/library/ingest", async ([FromBody] IngestEbookRequest request, ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); var epubData = Convert.FromBase64String(request.EpubDataBase64); byte[]? coverData = !string.IsNullOrEmpty(request.CoverImageBase64) ? Convert.FromBase64String(request.CoverImageBase64) : null; var command = new IngestEbookCommand( request.Title, request.AuthorName, coverData, epubData, request.Description, userId ); var result = await mediator.Send(command); if (result.IsSuccess) return Results.Ok(new { Id = result.Value }); return Results.BadRequest(result.Errors.FirstOrDefault()?.Message ?? "Ingestion failed"); }).RequireAuthorization().DisableAntiforgery(); app.MapGet("/api/library/books", async (ClaimsPrincipal user, IMediator mediator) => { var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrEmpty(userId)) return Results.Unauthorized(); var result = await mediator.Send(new GetMyEbooksQuery(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(); 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); } }).DisableAntiforgery(); 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.MapPost("/account/login-form", async ( [FromForm] string email, [FromForm] string password, [FromForm] bool rememberMe, [FromForm] string? returnUrl, SignInManager signInManager, ILogger logger) => { var result = await signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true); if (result.Succeeded) { logger.LogInformation("User logged in: {Email}", email); return Results.Redirect(returnUrl ?? "/"); } var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials"; return Results.Redirect($"/account/login?error={error}"); }).DisableAntiforgery(); // Simplified for now, in production use proper antiforgery app.MapGet("/account/logout-form", async ( SignInManager signInManager, ILogger logger) => { await signInManager.SignOutAsync(); logger.LogInformation("User logged out via form."); return Results.Redirect("/account/login"); }); 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); app.Run(); public record KnowledgeRequest(string Text, Guid? EbookId = null); public record GroundednessRequest(string Answer, string Context);