619 lines
24 KiB
C#
619 lines
24 KiB
C#
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 Server‑Side 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);
|