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

619 lines
24 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 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;
using Hangfire;
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.AddSignalR();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
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.AddScoped<ISyncService, SyncService>();
builder.Services.AddHttpClient("NexusAPI", (sp, client) =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
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<NavigationManager>();
client.BaseAddress = new Uri(nav.BaseUri);
}
});
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
builder.Services.AddScoped<IIdentityService, NexusReader.Web.Services.ServerIdentityService>();
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<IAuthorizationHandler, TokenLimitHandler>();
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<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>()
.AddClaimsPrincipalFactory<NexusReader.Web.Services.CustomUserClaimsPrincipalFactory>();
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<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();
app.UseHangfireDashboard();
// Startup Validation
using (var scope = app.Services.CreateScope())
{
var marker = scope.ServiceProvider.GetService<IInfrastructureMarker>();
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<ILogger<Program>>();
var dbContextFactory = services.GetRequiredService<IDbContextFactory<NexusReader.Data.Persistence.AppDbContext>>();
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);
await TriggerBackgroundProcessingForUnindexedBooksAsync(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<NexusReader.Infrastructure.RealTime.SyncHub>("/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.MapPost("/search", async (SemanticSearchRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
{
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await knowledgeService.SearchLibrarySemanticallyAsync(request.QueryText, tenantId, request.Limit);
if (result.IsSuccess) return Results.Ok(result.Value);
return Results.BadRequest(result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error");
});
knowledgeApi.MapPost("/ask", async (AskQuestionRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
{
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await knowledgeService.AskQuestionAsync(request.Question, tenantId, request.EbookId, request.Limit);
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 tenantId = user.FindFirst("TenantId")?.Value ?? "global";
var command = new IngestEbookCommand(
request.Title,
request.AuthorName,
coverData,
epubData,
request.Description,
userId,
tenantId
);
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<NexusUser> userManager,
IConfiguration configuration,
IDbContextFactory<AppDbContext> 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<string, string>? metadata,
UserManager<NexusUser> 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<NexusUser> 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<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,
ILogger<Program> 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<NexusUser> signInManager,
ILogger<Program> 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<NexusUser> signInManager,
ILogger<Program> 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<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(NexusReader.UI.Shared.Services.IKnowledgeGraphService).Assembly);
app.Run();
async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider services)
{
var logger = services.GetRequiredService<ILogger<Program>>();
try
{
var dbContextFactory = services.GetRequiredService<IDbContextFactory<NexusReader.Data.Persistence.AppDbContext>>();
using var dbContext = await dbContextFactory.CreateDbContextAsync();
var unindexedEbooks = await dbContext.Ebooks
.Where(e => !e.IsReadyForReading)
.ToListAsync();
if (unindexedEbooks.Any())
{
logger.LogInformation("[Startup] Found {Count} un-indexed ebooks. Triggering background processing...", unindexedEbooks.Count);
foreach (var ebook in unindexedEbooks)
{
logger.LogInformation("[Startup] Queuing background processing for ebook: '{Title}' ({Id})", ebook.Title, ebook.Id);
_ = Task.Run(async () =>
{
try
{
using var scope = services.CreateScope();
var scopedMediator = scope.ServiceProvider.GetRequiredService<IMediator>();
await scopedMediator.Send(new ProcessEbookCommand(ebook.Id, ebook.UserId, ebook.TenantId));
}
catch (Exception ex)
{
using var scope = services.CreateScope();
var scopedLogger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
scopedLogger.LogError(ex, "Failed to run background processing for ebook {EbookId} on startup", ebook.Id);
}
});
}
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error checking or triggering background processing for unindexed books on startup.");
}
}
public record KnowledgeRequest(string Text, Guid? EbookId = null);
public record GroundednessRequest(string Answer, string Context);
public record SemanticSearchRequest(string QueryText, int Limit = 5);
public record AskQuestionRequest(string Question, Guid? EbookId = null, int Limit = 5);