Refactor: Web Consolidation and Identity Stabilization #40

Merged
mjasin merged 37 commits from feature/issue-33 into develop 2026-05-11 19:16:31 +00:00
41 changed files with 1280 additions and 743 deletions
Showing only changes of commit e1f1a4b3cb - Show all commits
@@ -39,8 +39,8 @@ This skill defines the architectural guardrails for the NexusReader project to e
### 6. Database Schema Changes ### 6. Database Schema Changes
- Every change to a Domain entity or DbContext MUST be followed by the generation of a new EF Core migration. - Every change to a Domain entity or DbContext MUST be followed by the generation of a new EF Core migration.
- **Mandatory Commands**: - **Mandatory Commands**:
- `dotnet ef migrations add <MigrationName> --project src/NexusReader.Data --startup-project src/NexusReader.Web.New` - `dotnet ef migrations add <MigrationName> --project src/NexusReader.Data --startup-project src/NexusReader.Web`
- `dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web.New` - `dotnet ef database update --project src/NexusReader.Data --startup-project src/NexusReader.Web`
- Ensure the migration is applied to all local development environments before proceeding with feature verification. - Ensure the migration is applied to all local development environments before proceeding with feature verification.
## Audit Scripts ## Audit Scripts
+1 -1
View File
@@ -29,4 +29,4 @@ Thumbs.db
*.epub *.epub
.fake .fake
src/NexusReader.Web.New/nexus.db src/NexusReader.Web/nexus.db
+3 -3
View File
@@ -3,20 +3,20 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
# Copy csproj files and restore dependencies # Copy csproj files and restore dependencies
COPY ["src/NexusReader.Web.New/NexusReader.Web.csproj", "src/NexusReader.Web.New/"] COPY ["src/NexusReader.Web/NexusReader.Web.csproj", "src/NexusReader.Web/"]
COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"] COPY ["src/NexusReader.Web.Client/NexusReader.Web.Client.csproj", "src/NexusReader.Web.Client/"]
COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"] COPY ["src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"] COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"] COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"] COPY ["src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj", "src/NexusReader.Infrastructure/"]
RUN dotnet restore "src/NexusReader.Web.New/NexusReader.Web.csproj" RUN dotnet restore "src/NexusReader.Web/NexusReader.Web.csproj"
# Copy the rest of the source code # Copy the rest of the source code
COPY . . COPY . .
# Build and publish # Build and publish
WORKDIR "/src/src/NexusReader.Web.New" WORKDIR "/src/src/NexusReader.Web"
RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false RUN dotnet publish "NexusReader.Web.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Stage 2: Runtime # Stage 2: Runtime
+18 -16
View File
@@ -1,16 +1,18 @@
<Solution> <Solution>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/NexusReader.Application/NexusReader.Application.csproj" /> <Project Path="src/NexusReader.Application/NexusReader.Application.csproj" />
<Project Path="src/NexusReader.Data/NexusReader.Data.csproj" /> <Project Path="src/NexusReader.Domain/NexusReader.Domain.csproj" />
<Project Path="src/NexusReader.Domain/NexusReader.Domain.csproj" /> <Project Path="src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj" />
<Project Path="src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj" /> <Project Path="src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj" />
<Project Path="src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj" /> <Project Path="src/NexusReader.Web.Client/NexusReader.Web.Client.csproj" />
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" /> <Project Path="src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj" />
<Project Path="src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj" /> <Project Path="src/NexusReader.Data/NexusReader.Data.csproj" />
<Project Path="src/NexusReader.Web.Client/NexusReader.Web.Client.csproj" /> <Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
<Project Path="src/NexusReader.Web.New/NexusReader.Web.csproj" /> </Folder>
</Folder> <Folder Name="/src/NexusReader.Web/">
<Folder Name="/tests/"> <Project Path="src/NexusReader.Web/NexusReader.Web.csproj" />
<Project Path="tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj" /> </Folder>
</Folder> <Folder Name="/tests/">
</Solution> <Project Path="tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj" />
</Folder>
</Solution>
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ------------------------------------------------------------- # -------------------------------------------------------------
# Debug helper for NexusReader.Web.New (Blazor Server) # Debug helper for NexusReader.Web (Blazor Server)
# ------------------------------------------------------------- # -------------------------------------------------------------
# 1️⃣ Ensure the port is free before starting the server. # 1️⃣ Ensure the port is free before starting the server.
# 2️⃣ Starts the server project in the background. # 2️⃣ Starts the server project in the background.
@@ -10,7 +10,7 @@
# ------------------------------------------------------------- # -------------------------------------------------------------
# ---- configuration ------------------------------------------------ # ---- configuration ------------------------------------------------
SERVER_PROJECT="src/NexusReader.Web.New/NexusReader.Web.csproj" SERVER_PROJECT="src/NexusReader.Web/NexusReader.Web.csproj"
APP_URL="http://localhost:5104" APP_URL="http://localhost:5104"
DEBUG_PORT=9222 DEBUG_PORT=9222
TMP_PROFILE="/tmp/blazor-chrome-debug" TMP_PROFILE="/tmp/blazor-chrome-debug"
@@ -1,5 +1,6 @@
using FluentResults; using FluentResults;
using NexusReader.Application.Queries.Reader; using NexusReader.Application.Queries.Reader;
using System.IO;
namespace NexusReader.Application.Abstractions.Services; namespace NexusReader.Application.Abstractions.Services;
@@ -4,11 +4,13 @@ namespace NexusReader.Application.Abstractions.Services;
public interface INativeStorageService public interface INativeStorageService
{ {
Task<Result<string>> GetSecureString(string key); Result SaveString(string key, string value);
Result<string?> GetString(string key);
Result SaveBool(string key, bool value);
Result<bool> GetBool(string key, bool defaultValue = false);
Result Remove(string key);
Task<Result> SaveSecureString(string key, string value); Task<Result> SaveSecureString(string key, string value);
Task<Result<bool>> GetBool(string key); Task<Result<string?>> GetSecureString(string key);
Task<Result> SaveBool(string key, bool value); Result RemoveSecure(string key);
Task<Result<int>> GetInt(string key);
Task<Result> SaveInt(string key, int value);
Task<Result> ClearAll();
} }
@@ -2,14 +2,7 @@ namespace NexusReader.Application.Constants;
public static class PlanConstants public static class PlanConstants
{ {
public const string Free = "Free"; public const string DefaultPlanName = "Free";
public const string Pro = "Pro"; public const int DefaultTokenLimit = 1000;
public const string Enterprise = "Enterprise"; public const string DefaultActivityLabel = "Brak aktywności";
public static int GetTokenLimit(string planName) => planName switch
{
Pro => 100000,
Enterprise => 1000000,
_ => 10000
};
} }
@@ -2,11 +2,9 @@ namespace NexusReader.Application.Constants;
public static class StorageKeys public static class StorageKeys
{ {
public const string AuthToken = "authToken"; public const string AuthToken = "nexus_auth_token";
public const string RefreshToken = "refreshToken"; public const string RefreshToken = "nexus_refresh_token";
public const string UserEmail = "userEmail"; public const string UserEmail = "nexus_user_email";
public const string UserTenant = "userTenant"; public const string UserTenant = "nexus_user_tenant";
public const string UserRoles = "userRoles"; public const string UserRoles = "nexus_user_roles";
public const string Theme = "theme";
public const string FocusMode = "focusMode";
} }
@@ -5,28 +5,27 @@ namespace NexusReader.Application.DTOs.User;
public record UserProfileDto public record UserProfileDto
{ {
public string Email { get; init; } = string.Empty; public string Email { get; init; } = string.Empty;
public Guid TenantId { get; init; }
public SubscriptionPlanDto Plan { get; init; } = new();
public int AITokensUsed { get; init; } public int AITokensUsed { get; init; }
public string[] Roles { get; init; } = Array.Empty<string>(); public Guid TenantId { get; init; }
// Statistics for Dashboard /// <summary>
public int TotalBooksRead { get; init; } /// Relational data for the current subscription plan.
/// </summary>
public SubscriptionPlanDto Plan { get; init; } = new();
public int AverageQuizScore { get; init; } public int AverageQuizScore { get; init; }
/// <summary>
/// Summary of the last read book.
/// </summary>
public LastReadBookDto? LastReadBook { get; init; } public LastReadBookDto? LastReadBook { get; init; }
// UI Helpers public string[] Roles { get; init; } = Array.Empty<string>();
public string CurrentPlan => Plan.Name ?? PlanConstants.Free;
public int AITokenLimit => Plan.AITokenLimit > 0 ? Plan.AITokenLimit : PlanConstants.GetTokenLimit(CurrentPlan);
public string LastReadBookTitle => LastReadBook?.Title ?? "Brak aktywności";
}
public record SubscriptionPlanDto // Helper properties for UI compatibility
{ public string CurrentPlan => Plan?.Name ?? PlanConstants.DefaultPlanName;
public int Id { get; init; } public int AITokenLimit => Plan?.AITokenLimit ?? PlanConstants.DefaultTokenLimit;
public string Name { get; init; } = string.Empty; public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
public int AITokenLimit { get; init; }
public decimal MonthlyPrice { get; init; }
} }
public record LastReadBookDto public record LastReadBookDto
@@ -35,13 +34,7 @@ public record LastReadBookDto
public string Title { get; init; } = string.Empty; public string Title { get; init; } = string.Empty;
public AuthorDto Author { get; init; } = new(); public AuthorDto Author { get; init; } = new();
public string? CoverUrl { get; init; } public string? CoverUrl { get; init; }
public int Progress { get; init; } public double Progress { get; init; }
public string LastChapter { get; init; } = string.Empty; public string? LastChapter { get; init; }
public int LastChapterIndex { get; init; } public int LastChapterIndex { get; init; }
} }
public record AuthorDto
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
}
@@ -1,13 +1,14 @@
using Mapster; using Mapster;
using NexusReader.Application.DTOs.User; using MapsterMapper;
using NexusReader.Domain.Entities;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NexusReader.Domain.Entities;
using NexusReader.Application.DTOs.User;
namespace NexusReader.Application.Mappings; namespace NexusReader.Application.Mappings;
public static class MappingConfig public static class MappingConfig
{ {
public static void RegisterMappings(this IServiceCollection services) public static IServiceCollection AddMapsterConfiguration(this IServiceCollection services)
{ {
var config = TypeAdapterConfig.GlobalSettings; var config = TypeAdapterConfig.GlobalSettings;
@@ -16,5 +17,7 @@ public static class MappingConfig
services.AddSingleton(config); services.AddSingleton(config);
services.AddScoped<IMapper, ServiceMapper>(); services.AddScoped<IMapper, ServiceMapper>();
return services;
} }
} }
@@ -4,17 +4,17 @@ using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Application.Queries.Reader; namespace NexusReader.Application.Queries.Reader;
public class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel> internal sealed class GetReaderPageQueryHandler : IQueryHandler<GetReaderPageQuery, ReaderPageViewModel>
{ {
private readonly IEpubReader _epubReader; private readonly IEpubReader _epubService;
public GetReaderPageQueryHandler(IEpubReader epubReader) public GetReaderPageQueryHandler(IEpubReader epubService)
{ {
_epubReader = epubReader; _epubService = epubService;
} }
public async Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken) public async Task<Result<ReaderPageViewModel>> Handle(GetReaderPageQuery request, CancellationToken cancellationToken)
{ {
return await _epubReader.GetEpubContentAsync(request.ChapterIndex, request.UserId); return await _epubService.GetEpubContentAsync(request.ChapterIndex, request.UserId);
} }
} }
@@ -19,7 +19,7 @@ public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
} }
var basePath = currentDir != null var basePath = currentDir != null
? Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New") ? Path.Combine(currentDir.FullName, "src", "NexusReader.Web")
: Directory.GetCurrentDirectory(); : Directory.GetCurrentDirectory();
var configuration = new ConfigurationBuilder() var configuration = new ConfigurationBuilder()
@@ -1,16 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NexusReader.Application.Abstractions.Services; using Microsoft.Extensions.Configuration;
using Pgvector.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using GeminiDotnet;
using GeminiDotnet.Extensions.AI;
using NexusReader.Data.Persistence; using NexusReader.Data.Persistence;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Services; using NexusReader.Infrastructure.Services;
using Microsoft.AspNetCore.Identity; using NexusReader.Infrastructure.Configuration;
using Polly;
using Polly.Retry;
using NexusReader.Domain.Entities; using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using NexusReader.Application.Commands.Sync; using NexusReader.Application.Security.Authorization;
using NexusReader.Infrastructure.Handlers;
using MediatR;
namespace NexusReader.Infrastructure; namespace NexusReader.Infrastructure;
@@ -18,54 +23,73 @@ public static class DependencyInjection
{ {
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{ {
var connectionString = configuration.GetConnectionString("DefaultConnection") var pgConnectionString = configuration.GetConnectionString("PostgresConnection");
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); if (!string.IsNullOrEmpty(pgConnectionString))
// Register DB Context Factory for multi-threaded/asynchronous safety in Blazor
services.AddDbContextFactory<AppDbContext>(options =>
{ {
if (connectionString.Contains("Host=")) services.AddDbContextFactory<AppDbContext>(options =>
options.UseNpgsql(pgConnectionString, x => x.UseVector()));
}
else
{
var sqliteConnectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db";
services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite(sqliteConnectionString));
}
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
Console.WriteLine($"[Infrastructure] AI Configured: Model={aiSettings.Model}, KeyPresent={!string.IsNullOrWhiteSpace(aiSettings.ApiKey) && aiSettings.ApiKey != "PLACEHOLDER"}");
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
{
Console.WriteLine("[Infrastructure] WARNING: AI API Key is missing or placeholder!");
}
services.AddResiliencePipeline("ai-retry", builder =>
{
builder.AddRetry(new RetryStrategyOptions
{ {
options.UseNpgsql(connectionString, o => o.UseVector()); ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
} ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests") || ex.Message.Contains("quota")),
else BackoffType = DelayBackoffType.Exponential,
{ UseJitter = true,
options.UseSqlite(connectionString); MaxRetryAttempts = aiSettings.RetryAttempts,
} Delay = TimeSpan.FromSeconds(2)
});
}); });
// Register Scoped Context for traditional usage services.AddChatClient(new GeminiChatClient(new GeminiClientOptions
services.AddDbContext<AppDbContext>(options => {
ApiKey = aiSettings.ApiKey,
ModelId = aiSettings.Model
}));
services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions
{ {
if (connectionString.Contains("Host=")) ApiKey = aiSettings.ApiKey,
{ ModelId = aiSettings.EmbeddingModel ?? "text-embedding-004"
options.UseNpgsql(connectionString, o => o.UseVector()); }));
}
else
{
options.UseSqlite(connectionString);
}
});
// Identity Configuration
services.AddAuthorization(options =>
{
options.AddPolicy("TokenLimitPolicy", policy =>
policy.Requirements.Add(new TokenLimitRequirement()));
});
services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
// Services
services.AddScoped<IEpubReader, EpubReaderService>();
services.AddScoped<IEpubMetadataExtractor, EpubMetadataExtractor>();
services.AddScoped<IKnowledgeService, KnowledgeService>(); services.AddScoped<IKnowledgeService, KnowledgeService>();
services.AddScoped<IBillingService, BillingService>(); services.AddTransient<IEpubReader, EpubReaderService>();
services.AddSingleton<PromptRegistry>(); services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>();
// Handlers (MediatR) services.AddAuthorizationCore(options =>
services.AddScoped<IRequestHandler<UpdateReadingProgressCommand, FluentResults.Result>, UpdateReadingProgressCommandHandler>(); {
options.AddPolicy("ProUser", policy => policy.Requirements.Add(new ProUserRequirement()));
});
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
services.AddScoped<IInfrastructureMarker, InfrastructureMarker>();
return services; return services;
} }
public static System.Reflection.Assembly Assembly => typeof(DependencyInjection).Assembly;
} }
public interface IInfrastructureMarker { }
internal class InfrastructureMarker : IInfrastructureMarker { }
@@ -34,7 +34,7 @@ public class EpubReaderService : IEpubReader
while (currentDir != null) while (currentDir != null)
{ {
var checkPath1 = Path.Combine(currentDir.FullName, relativePath); var checkPath1 = Path.Combine(currentDir.FullName, relativePath);
var checkPath2 = Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New", relativePath); var checkPath2 = Path.Combine(currentDir.FullName, "src", "NexusReader.Web", relativePath);
searchPaths.Add(checkPath1); searchPaths.Add(checkPath1);
if (File.Exists(checkPath1)) { fullPath = checkPath1; break; } if (File.Exists(checkPath1)) { fullPath = checkPath1; break; }
@@ -215,6 +215,7 @@ public class EpubReaderService : IEpubReader
} }
return null; return null;
} }
// Metadata extraction moved to EpubMetadataExtractor
} }
public class EpubMetadataExtractor : IEpubMetadataExtractor public class EpubMetadataExtractor : IEpubMetadataExtractor
@@ -1,139 +1,149 @@
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using NexusReader.Application.Abstractions.Services @using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.Queries.Reader @using NexusReader.Application.Queries.Reader
@inject IEpubMetadataExtractor MetadataExtractor
@inject ILogger<BookIngestionModal> Logger
@implements IAsyncDisposable @implements IAsyncDisposable
@inject IEpubMetadataExtractor EpubMetadataExtractor
<div class="ingestion-modal @(IsVisible ? "visible" : "")" role="dialog" aria-labelledby="modal-title" aria-modal="true"> @if (IsOpen)
<div class="modal-overlay" @onclick="CloseModal"></div> {
<div class="modal-container"> <div class="modal-backdrop" @onclick="CloseModal">
<header class="modal-header"> <div class="modal-content glass-panel" @onclick:stopPropagation>
<NexusTypography Type="h2" id="modal-title">Dodaj nową książkę</NexusTypography> <div class="modal-header">
<NexusButton Variant="ghost" OnClick="CloseModal" aria-label="Zamknij"> <h2>Add New Book</h2>
<NexusIcon Name="close" Size="20" /> <button class="close-btn" @onclick="CloseModal">
</NexusButton> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</header> </button>
</div>
<section class="modal-content"> <div class="modal-body">
@if (Metadata == null && !IsParsing) <div class="parsing-state shimmer" style="@(IsParsing ? "display:flex;" : "display:none;")">
{ <div class="shimmer-content">
<div class="upload-zone @(IsDragging ? "dragging" : "")" <div class="spinner"></div>
@ondragenter="HandleDragEnter" <p>Scanning metadata...</p>
@ondragleave="HandleDragLeave"
@ondragover:preventDefault
@ondrop:preventDefault
@ondrop="HandleDrop">
<NexusIcon Name="upload" Size="48" />
<NexusTypography Type="body-large">Przeciągnij plik EPUB tutaj lub</NexusTypography>
<label class="file-input-label">
Przeglądaj pliki
<InputFile OnChange="HandleFileSelected" accept=".epub" />
</label>
</div>
}
else if (IsParsing)
{
<div class="parsing-state">
<div class="spinner"></div>
<NexusTypography Type="body">Analizowanie metadanych pliku...</NexusTypography>
</div>
}
else if (Metadata != null)
{
<div class="metadata-preview">
<div class="cover-wrapper">
@if (Metadata.CoverImage != null)
{
<img src="data:image/jpeg;base64,@Convert.ToBase64String(Metadata.CoverImage)" alt="Okładka @Metadata.Title" />
}
else
{
<div class="no-cover">
<NexusIcon Name="book" Size="40" />
</div>
}
</div> </div>
<div class="metadata-info"> </div>
<NexusTypography Type="h3">@Metadata.Title</NexusTypography>
<NexusTypography Type="body-muted">@Metadata.Author</NexusTypography> <div class="metadata-state" style="@(Metadata != null && !IsParsing ? "display:flex;" : "display:none;")">
@if (Metadata != null)
{
<div class="metadata-info">
<h3>@Metadata.Title</h3>
<p class="author">@Metadata.Author</p>
</div>
<div class="actions"> <div class="actions">
<NexusButton Variant="primary" OnClick="ConfirmIngestion">Dodaj do biblioteki</NexusButton> <button class="btn btn-primary">Confirm & Upload</button>
<NexusButton Variant="secondary" OnClick="Reset">Wybierz inny plik</NexusButton> <button class="btn btn-secondary" @onclick="Reset">Cancel</button>
</div>
}
</div>
<div class="upload-state @(_isDragging ? "drag-over" : "")"
style="@(!IsParsing && Metadata == null ? "display:flex;" : "display:none;")"
@ondragenter="OnDragEnter"
@ondragleave="OnDragLeave">
<div class="drop-zone">
<InputFile id="epub-upload" OnChange="HandleFileSelected" accept=".epub" class="file-input-cover" />
<div class="drop-zone-content">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
<p>Drag and drop your .epub file here</p>
<span>or click to browse</span>
</div> </div>
</div> </div>
</div> </div>
}
</section>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="error-message">
@ErrorMessage
</div>
}
</div>
</div>
</div> </div>
</div> }
@code { @code {
/// <summary> /// <summary>
/// Gets or sets a value indicating whether the modal is visible. /// Gets or sets a value indicating whether the modal is open.
/// </summary> /// </summary>
[Parameter] public bool IsVisible { get; set; } [Parameter]
public bool IsOpen { get; set; }
/// <summary>
/// Event callback triggered when the modal is closed.
/// </summary>
[Parameter] public EventCallback OnClose { get; set; }
/// <summary>
/// Event callback triggered when a book is successfully ingested.
/// Passes the extracted metadata to the parent component.
/// </summary>
[Parameter] public EventCallback<LocalEpubMetadata> OnBookAdded { get; set; }
private bool IsDragging { get; set; } /// <summary>
/// Event triggered when the IsOpen state changes.
/// </summary>
[Parameter]
public EventCallback<bool> IsOpenChanged { get; set; }
private bool _isDragging;
private bool IsParsing { get; set; } private bool IsParsing { get; set; }
private LocalEpubMetadata? Metadata { get; set; } private LocalEpubMetadata? Metadata { get; set; }
private string? ErrorMessage { get; set; }
// Allow up to 50 MB
private const long MaxFileSize = 50 * 1024 * 1024;
private async Task CloseModal() private async Task CloseModal()
{ {
Metadata = null; IsOpen = false;
await OnClose.InvokeAsync(); Reset();
await IsOpenChanged.InvokeAsync(false);
} }
private void HandleDragEnter() => IsDragging = true; private void Reset()
private void HandleDragLeave() => IsDragging = false;
private async Task HandleDrop(DragEventArgs e)
{ {
IsDragging = false; IsParsing = false;
// JS interop would be needed for actual drag-drop file access in Blazor WASM Metadata = null;
// This is a placeholder for the interaction pattern ErrorMessage = null;
_isDragging = false;
} }
private void OnDragEnter() => _isDragging = true;
private void OnDragLeave() => _isDragging = false;
private async Task HandleFileSelected(InputFileChangeEventArgs e) private async Task HandleFileSelected(InputFileChangeEventArgs e)
{ {
_isDragging = false;
var file = e.File; var file = e.File;
if (file == null) return; if (file == null) return;
if (!file.Name.EndsWith(".epub", StringComparison.OrdinalIgnoreCase))
{
ErrorMessage = "Only .epub files are supported.";
return;
}
ErrorMessage = null;
IsParsing = true; IsParsing = true;
StateHasChanged(); StateHasChanged();
try try
{ {
// Requirement: Extract metadata locally before uploading using var stream = file.OpenReadStream(MaxFileSize);
// We limit the size for extraction safety
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB
// Using MemoryStream to ensure the provider doesn't close the stream prematurely // In Blazor WASM, we might need to copy to memory stream first for synchronous parsing if the parser doesn't stream well over interop
using var memoryStream = new MemoryStream(); using var memoryStream = new MemoryStream();
await stream.CopyToAsync(memoryStream); await stream.CopyToAsync(memoryStream);
memoryStream.Position = 0; memoryStream.Position = 0;
var result = await EpubMetadataExtractor.ExtractMetadataAsync(memoryStream); var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream);
if (result.IsSuccess) if (result.IsSuccess)
{ {
Metadata = result.Value; Metadata = result.Value;
} }
else
{
ErrorMessage = result.Errors.FirstOrDefault()?.Message ?? "Failed to parse EPUB.";
}
} }
catch (Exception ex) catch (Exception ex)
{ {
// Log or handle error Logger.LogError(ex, "Error uploading EPUB");
Console.WriteLine($"Metadata extraction failed: {ex.Message}"); ErrorMessage = $"An unexpected error occurred: {ex.Message} \n {ex.StackTrace}";
} }
finally finally
{ {
@@ -141,22 +151,6 @@
StateHasChanged(); StateHasChanged();
} }
} }
private async Task ConfirmIngestion()
{
if (Metadata != null)
{
await OnBookAdded.InvokeAsync(Metadata);
await CloseModal();
}
}
private void Reset()
{
Metadata = null;
IsParsing = false;
}
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
// Cleanup if necessary // Cleanup if necessary
@@ -1,167 +1,272 @@
.ingestion-modal { .modal-backdrop {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
align-items: center;
z-index: 1000; z-index: 1000;
opacity: 0; animation: fadeIn 0.3s ease-out;
}
.modal-content {
background: linear-gradient(145deg, #1a1a1a 0%, #0a0a0a 100%);
border: 1px solid rgba(0, 255, 153, 0.2);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 255, 153, 0.05);
border-radius: 20px;
width: 90%;
max-width: 500px;
padding: 2.5rem;
display: flex;
flex-direction: column;
gap: 2rem;
position: relative;
overflow: hidden;
backdrop-filter: blur(16px);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
margin: 0;
font-family: var(--nexus-font-sans);
color: var(--nexus-text);
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
color: var(--nexus-text-muted, #888);
cursor: pointer;
transition: color 0.2s;
}
.close-btn:hover {
color: var(--nexus-neon, #00ffaa);
transform: rotate(90deg);
}
.modal-body {
min-height: 250px;
display: flex;
flex-direction: column;
justify-content: center;
}
/* Upload State */
.upload-state {
flex: 1;
display: flex;
}
.drop-zone {
flex: 1;
border: 2px dashed rgba(255, 255, 255, 0.1);
border-radius: 8px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.02);
position: relative;
}
.drop-zone:hover, .upload-state.drag-over .drop-zone {
border-color: var(--nexus-accent, #00ffaa);
background: rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.05);
}
.drop-zone-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: var(--nexus-text-muted, #888);
pointer-events: none; pointer-events: none;
transition: opacity 0.3s ease;
} }
.ingestion-modal.visible { .drop-zone-content svg {
opacity: 1; color: var(--nexus-accent, #00ffaa);
pointer-events: auto; opacity: 0.8;
} }
.modal-overlay { .drop-zone-content p {
margin: 0;
font-size: 1.1rem;
color: var(--nexus-text);
}
.drop-zone ::deep .file-input-cover {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(0, 0, 0, 0.6); opacity: 0;
backdrop-filter: blur(8px);
}
.modal-container {
position: relative;
width: 100%;
max-width: 500px;
background: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
padding: 32px;
transform: translateY(20px);
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.ingestion-modal.visible .modal-container {
transform: translateY(0);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.upload-zone {
border: 2px dashed var(--border-subtle);
border-radius: 20px;
padding: 48px 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.02);
}
.upload-zone.dragging {
border-color: var(--accent-primary);
background: rgba(var(--accent-primary-rgb), 0.05);
}
.file-input-label {
margin-top: 12px;
padding: 10px 24px;
background: var(--accent-primary);
color: white;
border-radius: 12px;
cursor: pointer; cursor: pointer;
font-weight: 500; z-index: 10;
transition: transform 0.2s ease;
}
.file-input-label:hover {
transform: scale(1.05);
}
.file-input-label input {
display: none;
} }
/* Parsing State */
.parsing-state { .parsing-state {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
position: relative;
overflow: hidden;
}
.shimmer::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 50%;
height: 100%;
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.05), transparent);
animation: shimmer 2s infinite;
}
.shimmer-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 16px; gap: 1rem;
padding: 40px 0;
} }
.spinner { .spinner {
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 3px solid rgba(var(--accent-primary-rgb), 0.1); border: 3px solid rgba(0, 255, 153, 0.1);
border-top-color: var(--accent-primary); border-top-color: var(--nexus-neon, #00ffaa);
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
filter: drop-shadow(0 0 8px rgba(0, 255, 153, 0.3));
}
.parsing-state p {
color: var(--nexus-text);
font-family: var(--nexus-font-mono, monospace);
font-size: 0.9rem;
letter-spacing: 1px;
}
/* Metadata State */
.metadata-state {
display: flex;
flex-direction: column;
gap: 2rem;
}
.metadata-info {
text-align: center;
}
.metadata-info h3 {
margin: 0 0 0.5rem 0;
color: var(--nexus-text);
font-size: 1.25rem;
}
.metadata-info .author {
margin: 0;
color: var(--nexus-text-muted, #888);
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1rem;
}
.btn {
font-family: var(--nexus-font-sans);
font-weight: 600;
padding: 0.75rem 1.5rem;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.85rem;
letter-spacing: 0.5px;
display: inline-flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
.btn-primary {
background: var(--nexus-neon, #00ffaa);
color: #050505;
box-shadow: 0 4px 12px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.2);
}
.btn-primary:hover {
background: #00e699;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(var(--nexus-accent-rgb, 0, 255, 170), 0.4);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.03);
color: var(--nexus-text);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.btn-secondary:active {
transform: translateY(0);
}
.error-message {
margin-top: 1rem;
color: #ff5555;
text-align: center;
font-size: 0.9rem;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes shimmer {
100% { left: 200%; }
} }
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
.metadata-preview {
display: flex;
gap: 24px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border-radius: 16px;
border: 1px solid var(--border-subtle);
}
.cover-wrapper {
width: 120px;
height: 180px;
border-radius: 8px;
overflow: hidden;
background: var(--surface-elevated);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
}
.cover-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
}
.metadata-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
}
.actions {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* Accessibility: Support reduced motion */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.ingestion-modal, .modal-backdrop,
.modal-container, .shimmer::before,
.file-input-label,
.upload-zone {
transition: none;
}
.spinner { .spinner {
animation-duration: 0s; animation: none !important;
transition: none !important;
} }
} }
@@ -125,7 +125,7 @@
var result = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe); var result = await IdentityService.LoginAsync(_loginModel.Email, _loginModel.Password, _loginModel.RememberMe);
if (result.IsSuccess) if (result.IsSuccess)
{ {
NavigationManager.NavigateTo("/"); NavigationManager.NavigateTo("/", forceLoad: true);
} }
else else
{ {
@@ -106,7 +106,7 @@
</div> </div>
@code { @code {
private UserProfile? _profile; private UserProfileDto? _profile;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -134,7 +134,7 @@
</div> </div>
@code { @code {
private UserProfile? _profile; private UserProfileDto? _profile;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -1,108 +1,118 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Constants; using NexusReader.Application.Constants;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
/**
* <summary>
* Custom AuthenticationStateProvider that manages user sessions using local storage.
* </summary>
* <remarks>
* SECURITY NOTE: Currently roles are stored in local storage as a comma-separated string
* for UI reactivity. In a production environment, roles should be extracted from a
* cryptographically signed JWT or validated via a back-channel to prevent client-side
* role escalation. Consider using ProtectedBrowserStorage for sensitive claims.
* </remarks>
*/
public class NexusAuthenticationStateProvider : AuthenticationStateProvider public class NexusAuthenticationStateProvider : AuthenticationStateProvider
{ {
private readonly INativeStorageService _storageService; private readonly INativeStorageService _storageService;
private AuthenticationState? _cachedState;
// SECURITY NOTE: We currently store roles in local storage to persist state across refreshes.
// In a production SaaS environment, consider using ProtectedBrowserStorage (Blazor Server)
// or encrypted storage/JWT claims validation to prevent client-side role tampering.
private const string TokenKey = StorageKeys.AuthToken;
public NexusAuthenticationStateProvider(INativeStorageService storageService) public NexusAuthenticationStateProvider(INativeStorageService storageService)
{ {
_storageService = storageService; _storageService = storageService;
} }
public void ClearCache()
{
_cachedState = null;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private AuthenticationState? _cachedState;
public override async Task<AuthenticationState> GetAuthenticationStateAsync() public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{ {
if (_cachedState != null) return _cachedState;
try try
{ {
var tokenResult = await _storageService.GetSecureString(StorageKeys.AuthToken); if (_cachedState != null) return _cachedState;
var tokenResult = await _storageService.GetSecureString(TokenKey);
var token = tokenResult.IsSuccess ? tokenResult.Value : null; var token = tokenResult.IsSuccess ? tokenResult.Value : null;
if (string.IsNullOrWhiteSpace(token)) // 1. Try Token-based auth
if (!string.IsNullOrWhiteSpace(token))
{ {
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
} var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail); if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
var tenantResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
var email = emailResult.IsSuccess ? emailResult.Value : "unknown";
var tenantId = tenantResult.IsSuccess ? tenantResult.Value : "default";
var roles = rolesResult.IsSuccess ? rolesResult.Value : "";
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, email),
new Claim("TenantId", tenantId)
}, "api");
if (!string.IsNullOrEmpty(roles))
{
foreach (var role in roles.Split(',', StringSplitOptions.RemoveEmptyEntries))
{ {
identity.AddClaim(new Claim(ClaimTypes.Role, role.Trim())); _cachedState = CreateState(
emailResult.Value,
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
"OpaqueBearer",
rolesResult.IsSuccess ? rolesResult.Value! : "");
return _cachedState;
} }
} }
_cachedState = new AuthenticationState(new ClaimsPrincipal(identity)); // 2. Try Cookie-based auth indicators
return _cachedState; var storedEmailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value))
{
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
_cachedState = CreateState(
storedEmailResult.Value,
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
"CookieAuth",
rolesResult.IsSuccess ? rolesResult.Value! : "");
return _cachedState;
}
// 3. Fallback: If we have no local info, we might still have a cookie (e.g. after refresh or Google login).
// We should return anonymous for now but trigger a background check if we're in WASM.
// Wait! In WASM, the first GetAuthenticationStateAsync is awaited.
// We can do a quick check here if it's the first time.
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
} }
catch catch (Exception)
{ {
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
} }
} }
public void NotifyUserAuthentication(string email, string tenantId, string roles = "") private AuthenticationState CreateState(string email, string tenantId, string authType, string rolesStr = "")
{ {
var identity = new ClaimsIdentity(new[] var claims = new List<Claim>
{ {
new Claim(ClaimTypes.Name, email), new Claim(ClaimTypes.Name, email),
new Claim(ClaimTypes.Email, email),
new Claim("TenantId", tenantId) new Claim("TenantId", tenantId)
}, "api"); };
if (!string.IsNullOrEmpty(roles)) if (!string.IsNullOrEmpty(rolesStr))
{ {
foreach (var role in roles.Split(',', StringSplitOptions.RemoveEmptyEntries)) var roles = rolesStr.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var role in roles)
{ {
identity.AddClaim(new Claim(ClaimTypes.Role, role.Trim())); claims.Add(new Claim(ClaimTypes.Role, role.Trim()));
} }
} }
var identity = new ClaimsIdentity(claims, authType);
return new AuthenticationState(new ClaimsPrincipal(identity));
}
var user = new ClaimsPrincipal(identity); public void NotifyUserAuthentication(string email, string tenantId, string rolesStr = "")
_cachedState = new AuthenticationState(user); {
var authState = Task.FromResult(_cachedState); _cachedState = CreateState(email, tenantId, "OpaqueBearer", rolesStr);
NotifyAuthenticationStateChanged(authState); NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
} }
public void NotifyUserLogout() public void NotifyUserLogout()
{
var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
_cachedState = new AuthenticationState(anonymousUser);
var authState = Task.FromResult(_cachedState);
NotifyAuthenticationStateChanged(authState);
}
public void ClearCache()
{ {
_cachedState = null; _cachedState = null;
var guest = new ClaimsPrincipal(new ClaimsIdentity());
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest)));
} }
} }
+3
View File
@@ -16,3 +16,6 @@
@using NexusReader.UI.Shared.Components.Organisms @using NexusReader.UI.Shared.Components.Organisms
@using NexusReader.UI.Shared.Services @using NexusReader.UI.Shared.Services
@using Microsoft.Extensions.Logging @using Microsoft.Extensions.Logging
@using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User
@using NexusReader.Application.Queries.Reader
+45 -31
View File
@@ -1,52 +1,66 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using NexusReader.Web.Client; using Microsoft.AspNetCore.Components.Authorization;
using NexusReader.UI.Shared.Services;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Web.Client.Services; using NexusReader.Web.Client.Services;
using Microsoft.AspNetCore.Components.Authorization; using NexusReader.UI.Shared.Services;
using NexusReader.Web.Client.Handlers;
using NexusReader.Application; using NexusReader.Application;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using NexusReader.Data.Persistence;
var builder = WebAssemblyHostBuilder.CreateDefault(args); var builder = WebAssemblyHostBuilder.CreateDefault(args);
// --- Identity & Auth --- // Platform & UI Services
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, NexusAuthenticationStateProvider>();
// --- Storage & Platform ---
builder.Services.AddScoped<INativeStorageService, WebStorageService>();
builder.Services.AddScoped<IPlatformService, WebPlatformService>(); builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, WebStorageService>();
// --- Identity Service (WASM) ---
builder.Services.AddScoped<IIdentityService, IdentityService>();
// --- App Services ---
builder.Services.AddScoped<IEpubReader, WasmEpubReader>();
builder.Services.AddScoped<IEpubMetadataExtractor, WasmEpubMetadataExtractor>();
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IThemeService, ThemeService>(); builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>(); builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>(); builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>(); builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<ISyncService, SyncService>();
builder.Services.AddScoped<KnowledgeCoordinator>(); builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>();
// --- Application Layer (Mappings etc) --- // Identity & Auth Services
builder.Services.AddApplication(); builder.Services.AddOptions();
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<IIdentityService, IdentityService>();
builder.Services.AddScoped<NexusAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<NexusAuthenticationStateProvider>());
builder.Services.AddCascadingAuthenticationState();
// --- HttpClient Configuration --- // AI & Content Services
builder.Services.AddTransient<AuthenticationHeaderHandler>(); builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
builder.Services.AddTransient<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>();
builder.Services.AddHttpClient("NexusAPI", client => builder.Services.AddHttpClient("NexusAPI", client =>
{ {
client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress); client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
}) }).AddHttpMessageHandler<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>();
.AddHttpMessageHandler<AuthenticationHeaderHandler>();
// Default HttpClient for services that don't need the header handler (or handle it themselves)
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI")); builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
// Dummy registrations for server-only handlers to satisfy DI validation
builder.Services.AddSingleton<IDbContextFactory<AppDbContext>>(new ThrowingDbContextFactory());
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(new ThrowingEmbeddingGenerator());
builder.Services.AddApplication();
builder.Services.AddScoped<IEpubReader, WasmEpubReader>();
builder.Services.AddScoped<IEpubMetadataExtractor, WasmEpubMetadataExtractor>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
public class ThrowingDbContextFactory : IDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext() => throw new NotSupportedException("DbContext cannot be used in WASM client.");
}
public class ThrowingEmbeddingGenerator : IEmbeddingGenerator<string, Embedding<float>>
{
public void Dispose() { }
public Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(IEnumerable<string> values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Embedding generation cannot be used in WASM client.");
public object? GetService(Type serviceType, object? serviceKey = null) => null;
}
@@ -2,7 +2,6 @@ using System.Net.Http.Json;
using FluentResults; using FluentResults;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Reader; using NexusReader.Application.Queries.Reader;
using VersOne.Epub;
namespace NexusReader.Web.Client.Services; namespace NexusReader.Web.Client.Services;
@@ -19,19 +18,24 @@ public class WasmEpubReader : IEpubReader
{ {
try try
{ {
var response = await _httpClient.GetAsync($"api/epub/content?index={chapterIndex}&userId={userId}"); var response = await _httpClient.GetAsync($"/api/epub/{chapterIndex}");
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
var result = await response.Content.ReadFromJsonAsync<ReaderPageViewModel>(); var viewModel = await response.Content.ReadFromJsonAsync<ReaderPageViewModel>();
return result != null ? Result.Ok(result) : Result.Fail("Failed to deserialize reader page."); return viewModel != null ? Result.Ok(viewModel) : Result.Fail("Failed to deserialize response.");
} }
return Result.Fail("Failed to fetch EPUB content from server.");
// Try to read the error message from the body
var errorBody = await response.Content.ReadAsStringAsync();
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
} }
catch (Exception ex) catch (Exception ex)
{ {
return Result.Fail(new Error("Network error while fetching EPUB content.").CausedBy(ex)); // Fallback for network errors or parsing exceptions
return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex));
} }
} }
// Metadata extraction moved to WasmEpubMetadataExtractor
} }
public class WasmEpubMetadataExtractor : IEpubMetadataExtractor public class WasmEpubMetadataExtractor : IEpubMetadataExtractor
@@ -40,7 +44,7 @@ public class WasmEpubMetadataExtractor : IEpubMetadataExtractor
{ {
try try
{ {
using var bookRef = await EpubReader.OpenBookAsync(epubStream); using var bookRef = await VersOne.Epub.EpubReader.OpenBookAsync(epubStream);
var title = bookRef.Title ?? "Unknown Title"; var title = bookRef.Title ?? "Unknown Title";
var author = bookRef.Author ?? "Unknown Author"; var author = bookRef.Author ?? "Unknown Author";
byte[]? cover = await bookRef.ReadCoverAsync(); byte[]? cover = await bookRef.ReadCoverAsync();
-111
View File
@@ -1,111 +0,0 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Reader;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Configuration;
using NexusReader.Infrastructure.RealTime;
using NexusReader.UI.Shared.Services;
using NexusReader.Web.Components;
using NexusReader.Web.New.Services;
using NexusReader.Infrastructure;
using NexusReader.Application;
var builder = WebApplication.CreateBuilder(args);
// --- Configuration ---
builder.Services.Configure<AiSettings>(builder.Configuration.GetSection("AiSettings"));
builder.Services.Configure<StripeSettings>(builder.Configuration.GetSection("StripeSettings"));
// --- Infrastructure Layer ---
builder.Services.AddInfrastructure(builder.Configuration);
// --- Application Layer ---
builder.Services.AddApplication();
// --- Identity & Auth ---
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<AuthenticationStateProvider, NexusAuthenticationStateProvider>();
// Register Server-Side Native Storage (JSInterop Wrapper)
builder.Services.AddScoped<INativeStorageService, NativeStorageService>();
// Register Server-Side Identity Service
builder.Services.AddScoped<IIdentityService, ServerIdentityService>();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
.AddIdentityCookies();
builder.Services.AddIdentityCore<NexusUser>(options => options.SignIn.RequireConfirmedAccount = true)
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
// --- Razor Components ---
builder.Services.AddRazorComponents()
.AddInteractiveWebAssemblyComponents()
.AddInteractiveServerComponents();
// --- API Client for WASM Compatibility ---
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "https://localhost:7165/") });
var app = builder.Build();
// --- Database Initialization ---
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
try
{
var context = services.GetRequiredService<AppDbContext>();
if (context.Database.IsRelational())
{
await context.Database.MigrateAsync();
}
await DbInitializer.InitializeAsync(services);
}
catch (Exception ex)
{
var logger = services.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "An error occurred while migrating or seeding the database.");
}
}
// --- Middleware Pipeline ---
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveWebAssemblyRenderMode()
.AddInteractiveServerRenderMode()
.AddAdditionalAssemblies(typeof(NexusReader.UI.Shared._Imports).Assembly)
.AddAdditionalAssemblies(typeof(NexusReader.Web.Client._Imports).Assembly);
// --- API Endpoints ---
app.MapHub<SyncHub>("/hubs/sync");
app.MapGet("/api/epub/content", async (int index, string? userId, IEpubReader epubReader) =>
{
var result = await epubReader.GetEpubContentAsync(index, userId);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Errors);
});
app.Run();
@@ -1,82 +0,0 @@
using FluentResults;
using Microsoft.JSInterop;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.New.Services;
/// <summary>
/// Server-side implementation of INativeStorageService for Blazor Server.
/// Uses JS Interop to access browser local storage.
/// </summary>
public class NativeStorageService : INativeStorageService
{
private readonly IJSRuntime _jsRuntime;
public NativeStorageService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<Result<string>> GetSecureString(string key)
{
try
{
var value = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", key);
return Result.Ok(value ?? string.Empty);
}
catch (Exception ex)
{
return Result.Fail(new Error("Failed to read from local storage").CausedBy(ex));
}
}
public async Task<Result> SaveSecureString(string key, string value)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error("Failed to write to local storage").CausedBy(ex));
}
}
public async Task<Result<bool>> GetBool(string key)
{
var result = await GetSecureString(key);
if (result.IsFailed) return Result.Fail(result.Errors);
return Result.Ok(bool.TryParse(result.Value, out var val) && val);
}
public async Task<Result> SaveBool(string key, bool value)
{
return await SaveSecureString(key, value.ToString().ToLower());
}
public async Task<Result<int>> GetInt(string key)
{
var result = await GetSecureString(key);
if (result.IsFailed) return Result.Fail(result.Errors);
return Result.Ok(int.TryParse(result.Value, out var val) ? val : 0);
}
public async Task<Result> SaveInt(string key, int value)
{
return await SaveSecureString(key, value.ToString());
}
public async Task<Result> ClearAll()
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.clear");
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error("Failed to clear local storage").CausedBy(ex));
}
}
}
@@ -1,70 +0,0 @@
using System.Security.Claims;
using FluentResults;
using Microsoft.AspNetCore.Identity;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.DTOs.User;
using NexusReader.Domain.Entities;
using Mapster;
using Microsoft.EntityFrameworkCore;
using NexusReader.Data.Persistence;
namespace NexusReader.Web.New.Services;
public class ServerIdentityService : IIdentityService
{
private readonly UserManager<NexusUser> _userManager;
private readonly SignInManager<NexusUser> _signInManager;
private readonly INativeStorageService _storageService;
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public event Func<Task>? OnStateInvalidated;
public ServerIdentityService(
UserManager<NexusUser> userManager,
SignInManager<NexusUser> signInManager,
INativeStorageService storageService,
IDbContextFactory<AppDbContext> contextFactory)
{
_userManager = userManager;
_signInManager = signInManager;
_storageService = storageService;
_contextFactory = contextFactory;
}
public async Task<Result> RegisterAsync(string email, string password)
{
var user = new NexusUser { UserName = email, Email = email };
var result = await _userManager.CreateAsync(user, password);
return result.Succeeded ? Result.Ok() : Result.Fail(result.Errors.Select(e => e.Description));
}
public async Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
{
var result = await _signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: false);
if (result.Succeeded)
{
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
return Result.Ok();
}
return Result.Fail("Login failed");
}
public async Task<Result> LogoutAsync()
{
await _signInManager.SignOutAsync();
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
return Result.Ok();
}
public async Task<Result<UserProfileDto>> GetProfileAsync()
{
// This is a simplified server-side profile fetch
// In a real app, you'd get the current user from HttpContext
return Result.Fail("Profile fetch not implemented on server identity service yet");
}
public Task<Result> RefreshTokenAsync()
{
return Task.FromResult(Result.Ok());
}
}
+455
View File
@@ -0,0 +1,455 @@
using NexusReader.Web.Components;
using NexusReader.Application;
using NexusReader.Infrastructure;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.User;
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;
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", client =>
{
client.BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5000");
});
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
builder.Services.AddHttpContextAccessor();
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>();
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();
// 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);
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();
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
// API endpoint for WASM client to fetch EPUB content
app.MapGet("/api/epub/{index}", async (int index, IEpubReader epubService, ClaimsPrincipal user) =>
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await epubService.GetEpubContentAsync(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");
knowledgeApi.MapPost("/", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
{
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await knowledgeService.GetKnowledgeAsync(request.Text, 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("/graph", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
{
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await knowledgeService.GetGraphDataAsync(request.Text, 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("/summary", async (KnowledgeRequest request, ClaimsPrincipal user, IKnowledgeService knowledgeService) =>
{
var tenantId = user.FindFirstValue("TenantId") ?? "global";
var result = await knowledgeService.GetSummaryAndQuizAsync(request.Text, 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("/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/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);
}
});
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.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();
public record KnowledgeRequest(string Text);
public record GroundednessRequest(string Answer, string Context);
@@ -0,0 +1,80 @@
using FluentResults;
using Microsoft.JSInterop;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Services;
/// <summary>
/// Server-side implementation of INativeStorageService.
/// Note: In Blazor Server, localStorage is only accessible via JS Interop.
/// This implementation handles cases where JS Interop might not be available (e.g. during prerendering).
/// </summary>
public class NativeStorageService : INativeStorageService
{
private readonly Microsoft.JSInterop.IJSRuntime _jsRuntime;
public NativeStorageService(Microsoft.JSInterop.IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public Result SaveString(string key, string value)
{
try
{
_jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result<string?> GetString(string key) => Result.Fail("Async retrieval required for server-side storage.");
public Result SaveBool(string key, bool value) => SaveString(key, value.ToString());
public Result<bool> GetBool(string key, bool defaultValue = false) => Result.Ok(defaultValue);
public Result Remove(string key)
{
try
{
_jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result> SaveSecureString(string key, string value)
{
try
{
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public async Task<Result<string?>> GetSecureString(string key)
{
try
{
var value = await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
return Result.Ok(value);
}
catch (Exception ex)
{
return Result.Fail(ex.Message);
}
}
public Result RemoveSecure(string key) => Remove(key);
}
@@ -0,0 +1,131 @@
using System.Security.Claims;
using FluentResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using NexusReader.Application.DTOs.User;
using NexusReader.Data.Persistence;
using NexusReader.Domain.Entities;
using NexusReader.Application.Queries.User;
using MediatR;
using NexusReader.Application.Constants;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Web.Services;
public class ServerIdentityService : IIdentityService
{
private readonly UserManager<NexusUser> _userManager;
private readonly SignInManager<NexusUser> _signInManager;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IMediator _mediator;
private readonly INativeStorageService _storageService;
public event Func<Task>? OnStateInvalidated;
public ServerIdentityService(
UserManager<NexusUser> userManager,
SignInManager<NexusUser> signInManager,
IHttpContextAccessor httpContextAccessor,
IMediator mediator,
INativeStorageService storageService)
{
_userManager = userManager;
_signInManager = signInManager;
_httpContextAccessor = httpContextAccessor;
_mediator = mediator;
_storageService = storageService;
}
public async Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
{
try
{
var user = await _userManager.FindByEmailAsync(email);
if (user == null) return Result.Fail("Nieprawidłowy e-mail lub hasło.");
var result = await _signInManager.PasswordSignInAsync(user, password, rememberMe, lockoutOnFailure: true);
if (result.Succeeded) return Result.Ok();
if (result.IsLockedOut) return Result.Fail("Konto zostało zablokowane.");
if (result.RequiresTwoFactor) return Result.Fail("Wymagana weryfikacja dwuetapowa.");
return Result.Fail("Nieprawidłowy e-mail lub hasło.");
}
catch (Exception ex)
{
return Result.Fail(new Error($"Błąd podczas logowania na serwerze: {ex.Message}").CausedBy(ex));
}
}
public async Task<Result> LogoutAsync()
{
try
{
await _signInManager.SignOutAsync();
// Clear storage if available (Interactive Server mode)
try
{
await _storageService.SaveSecureString(StorageKeys.AuthToken, "");
await _storageService.SaveSecureString(StorageKeys.RefreshToken, "");
await _storageService.SaveSecureString(StorageKeys.UserEmail, "");
await _storageService.SaveSecureString(StorageKeys.UserTenant, "");
await _storageService.SaveSecureString(StorageKeys.UserRoles, "");
}
catch
{
// Ignore errors during prerendering where JS interop isn't available
}
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
return Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error("Logout failed.").CausedBy(ex));
}
}
public async Task<Result> RegisterAsync(string email, string password)
{
try
{
var user = new NexusUser
{
UserName = email,
Email = email,
SubscriptionPlanId = SubscriptionPlan.FreeId,
TenantId = "global"
};
var result = await _userManager.CreateAsync(user, password);
if (result.Succeeded)
{
await _signInManager.SignInAsync(user, isPersistent: false);
return Result.Ok();
}
return Result.Fail(result.Errors.Select(e => e.Description).FirstOrDefault() ?? "Rejestracja nie powiodła się.");
}
catch (Exception ex)
{
return Result.Fail(new Error($"Błąd podczas rejestracji na serwerze: {ex.Message}").CausedBy(ex));
}
}
public Task<Result> RefreshTokenAsync() => Task.FromResult(Result.Ok());
public async Task<Result<UserProfileDto>> GetProfileAsync()
{
var user = _httpContextAccessor.HttpContext?.User;
if (user == null || !user.Identity?.IsAuthenticated == true) return Result.Fail("Not authenticated.");
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null) return Result.Fail("User ID not found.");
var result = await _mediator.Send(new GetUserProfileQuery(userId));
if (result.IsFailed) return Result.Fail(result.Errors);
return Result.Ok(result.Value);
}
}
@@ -1,29 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PackageReference Include="FluentAssertions" Version="6.12.0" />
<PrivateAssets>all</PrivateAssets> <PackageReference Include="Moq" Version="4.20.70" />
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" /> <ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" /> <ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,3 +1,6 @@
using System.IO;
using System.Threading.Tasks;
using FluentAssertions;
using NexusReader.Infrastructure.Services; using NexusReader.Infrastructure.Services;
using Xunit; using Xunit;
@@ -5,24 +8,18 @@ namespace NexusReader.Application.Tests.Services;
public class EpubMetadataExtractorTests public class EpubMetadataExtractorTests
{ {
private readonly EpubMetadataExtractor _sut;
public EpubMetadataExtractorTests()
{
_sut = new EpubMetadataExtractor();
}
[Fact] [Fact]
public async Task ExtractMetadataAsync_WithInvalidStream_ReturnsFailure() public async Task ExtractMetadataAsync_WithInvalidStream_ReturnsFailure()
{ {
// Arrange // Arrange
using var invalidStream = new MemoryStream(new byte[] { 0, 1, 2, 3 }); var extractor = new EpubMetadataExtractor();
using var stream = new MemoryStream(new byte[] { 0, 1, 2, 3 });
// Act // Act
var result = await _sut.ExtractMetadataAsync(invalidStream); var result = await extractor.ExtractMetadataAsync(stream);
// Assert // Assert
Assert.True(result.IsFailed); result.IsSuccess.Should().BeFalse();
Assert.Contains("Failed to extract EPUB metadata", result.Errors[0].Message); result.Errors.Should().NotBeEmpty();
} }
} }