376 lines
13 KiB
C#
376 lines
13 KiB
C#
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<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);
|