Files
Nexus.Reader/src/NexusReader.Web.New/Program.cs
T

376 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ServerSide Blazor components
builder.Services.AddServerSideBlazor()
.AddCircuitOptions(options =>
{
options.DetailedErrors = true;
});
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, NexusReader.UI.Shared.Services.WebStorageService>();
builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddHttpClient("NexusAPI", client =>
{
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5000");
});
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
builder.Services.AddScoped<IIdentityService, NexusReader.UI.Shared.Services.IdentityService>();
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// Authorization Policies
builder.Services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("ProUser", policy => policy.RequireClaim("Plan", "Pro", "Enterprise"))
.AddPolicy("HasAvailableTokens", policy => policy.AddRequirements(new TokenLimitRequirement()));
// Billing & Stripe
builder.Services.AddScoped<IBillingService, NexusReader.Infrastructure.Services.BillingService>();
// 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<NexusUser>()
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>();
builder.Services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/account/login";
options.Cookie.HttpOnly = true;
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
});
builder.Services.Configure<IdentityOptions>(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<ILogger<Program>>();
var dbContext = services.GetRequiredService<NexusReader.Infrastructure.Persistence.AppDbContext>();
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<NexusUser> 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<string, string>? metadata, UserManager<NexusUser> 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<NexusUser> 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<NexusUser>();
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<NexusUser> signInManager,
UserManager<NexusUser> 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<NexusUser> 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<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(NexusReader.UI.Shared.Services.IKnowledgeGraphService).Assembly);
app.Run();
public record KnowledgeRequest(string Text);