Refactor: Web Consolidation and Identity Stabilization #40
@@ -39,8 +39,8 @@ This skill defines the architectural guardrails for the NexusReader project to e
|
||||
### 6. Database Schema Changes
|
||||
- Every change to a Domain entity or DbContext MUST be followed by the generation of a new EF Core migration.
|
||||
- **Mandatory Commands**:
|
||||
- `dotnet ef migrations add <MigrationName> --project src/NexusReader.Data --startup-project src/NexusReader.Web.New`
|
||||
- `dotnet ef database update --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`
|
||||
- Ensure the migration is applied to all local development environments before proceeding with feature verification.
|
||||
|
||||
## Audit Scripts
|
||||
|
||||
+1
-1
@@ -29,4 +29,4 @@ Thumbs.db
|
||||
*.epub
|
||||
|
||||
.fake
|
||||
src/NexusReader.Web.New/nexus.db
|
||||
src/NexusReader.Web/nexus.db
|
||||
|
||||
+3
-3
@@ -3,20 +3,20 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# 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.UI.Shared/NexusReader.UI.Shared.csproj", "src/NexusReader.UI.Shared/"]
|
||||
COPY ["src/NexusReader.Application/NexusReader.Application.csproj", "src/NexusReader.Application/"]
|
||||
COPY ["src/NexusReader.Domain/NexusReader.Domain.csproj", "src/NexusReader.Domain/"]
|
||||
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 . .
|
||||
|
||||
# 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
|
||||
|
||||
# Stage 2: Runtime
|
||||
|
||||
+7
-5
@@ -1,14 +1,16 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<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.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj" />
|
||||
<Project Path="src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj" />
|
||||
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
|
||||
<Project Path="src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj" />
|
||||
<Project Path="src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj" />
|
||||
<Project Path="src/NexusReader.Web.Client/NexusReader.Web.Client.csproj" />
|
||||
<Project Path="src/NexusReader.Web.New/NexusReader.Web.csproj" />
|
||||
<Project Path="src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj" />
|
||||
<Project Path="src/NexusReader.Data/NexusReader.Data.csproj" />
|
||||
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/NexusReader.Web/">
|
||||
<Project Path="src/NexusReader.Web/NexusReader.Web.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj" />
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
#!/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.
|
||||
# 2️⃣ Starts the server project in the background.
|
||||
@@ -10,7 +10,7 @@
|
||||
# -------------------------------------------------------------
|
||||
|
||||
# ---- configuration ------------------------------------------------
|
||||
SERVER_PROJECT="src/NexusReader.Web.New/NexusReader.Web.csproj"
|
||||
SERVER_PROJECT="src/NexusReader.Web/NexusReader.Web.csproj"
|
||||
APP_URL="http://localhost:5104"
|
||||
DEBUG_PORT=9222
|
||||
TMP_PROFILE="/tmp/blazor-chrome-debug"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using FluentResults;
|
||||
using NexusReader.Application.Queries.Reader;
|
||||
using System.IO;
|
||||
|
||||
namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ namespace NexusReader.Application.Abstractions.Services;
|
||||
|
||||
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<bool>> GetBool(string key);
|
||||
Task<Result> SaveBool(string key, bool value);
|
||||
Task<Result<int>> GetInt(string key);
|
||||
Task<Result> SaveInt(string key, int value);
|
||||
Task<Result> ClearAll();
|
||||
Task<Result<string?>> GetSecureString(string key);
|
||||
Result RemoveSecure(string key);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,7 @@ namespace NexusReader.Application.Constants;
|
||||
|
||||
public static class PlanConstants
|
||||
{
|
||||
public const string Free = "Free";
|
||||
public const string Pro = "Pro";
|
||||
public const string Enterprise = "Enterprise";
|
||||
|
||||
public static int GetTokenLimit(string planName) => planName switch
|
||||
{
|
||||
Pro => 100000,
|
||||
Enterprise => 1000000,
|
||||
_ => 10000
|
||||
};
|
||||
public const string DefaultPlanName = "Free";
|
||||
public const int DefaultTokenLimit = 1000;
|
||||
public const string DefaultActivityLabel = "Brak aktywności";
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ namespace NexusReader.Application.Constants;
|
||||
|
||||
public static class StorageKeys
|
||||
{
|
||||
public const string AuthToken = "authToken";
|
||||
public const string RefreshToken = "refreshToken";
|
||||
public const string UserEmail = "userEmail";
|
||||
public const string UserTenant = "userTenant";
|
||||
public const string UserRoles = "userRoles";
|
||||
public const string Theme = "theme";
|
||||
public const string FocusMode = "focusMode";
|
||||
public const string AuthToken = "nexus_auth_token";
|
||||
public const string RefreshToken = "nexus_refresh_token";
|
||||
public const string UserEmail = "nexus_user_email";
|
||||
public const string UserTenant = "nexus_user_tenant";
|
||||
public const string UserRoles = "nexus_user_roles";
|
||||
}
|
||||
|
||||
@@ -5,28 +5,27 @@ namespace NexusReader.Application.DTOs.User;
|
||||
public record UserProfileDto
|
||||
{
|
||||
public string Email { get; init; } = string.Empty;
|
||||
public Guid TenantId { get; init; }
|
||||
public SubscriptionPlanDto Plan { get; init; } = new();
|
||||
public int AITokensUsed { get; init; }
|
||||
public string[] Roles { get; init; } = Array.Empty<string>();
|
||||
public Guid TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relational data for the current subscription plan.
|
||||
/// </summary>
|
||||
public SubscriptionPlanDto Plan { get; init; } = new();
|
||||
|
||||
// Statistics for Dashboard
|
||||
public int TotalBooksRead { get; init; }
|
||||
public int AverageQuizScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Summary of the last read book.
|
||||
/// </summary>
|
||||
public LastReadBookDto? LastReadBook { get; init; }
|
||||
|
||||
// UI Helpers
|
||||
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 string[] Roles { get; init; } = Array.Empty<string>();
|
||||
|
||||
public record SubscriptionPlanDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public int AITokenLimit { get; init; }
|
||||
public decimal MonthlyPrice { get; init; }
|
||||
// Helper properties for UI compatibility
|
||||
public string CurrentPlan => Plan?.Name ?? PlanConstants.DefaultPlanName;
|
||||
public int AITokenLimit => Plan?.AITokenLimit ?? PlanConstants.DefaultTokenLimit;
|
||||
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
|
||||
}
|
||||
|
||||
public record LastReadBookDto
|
||||
@@ -35,13 +34,7 @@ public record LastReadBookDto
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public AuthorDto Author { get; init; } = new();
|
||||
public string? CoverUrl { get; init; }
|
||||
public int Progress { get; init; }
|
||||
public string LastChapter { get; init; } = string.Empty;
|
||||
public double Progress { get; init; }
|
||||
public string? LastChapter { 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 NexusReader.Application.DTOs.User;
|
||||
using NexusReader.Domain.Entities;
|
||||
using MapsterMapper;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
|
||||
namespace NexusReader.Application.Mappings;
|
||||
|
||||
public static class MappingConfig
|
||||
{
|
||||
public static void RegisterMappings(this IServiceCollection services)
|
||||
public static IServiceCollection AddMapsterConfiguration(this IServiceCollection services)
|
||||
{
|
||||
var config = TypeAdapterConfig.GlobalSettings;
|
||||
|
||||
@@ -16,5 +17,7 @@ public static class MappingConfig
|
||||
|
||||
services.AddSingleton(config);
|
||||
services.AddScoped<IMapper, ServiceMapper>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@ using NexusReader.Application.Abstractions.Services;
|
||||
|
||||
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)
|
||||
{
|
||||
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
|
||||
? Path.Combine(currentDir.FullName, "src", "NexusReader.Web.New")
|
||||
? Path.Combine(currentDir.FullName, "src", "NexusReader.Web")
|
||||
: Directory.GetCurrentDirectory();
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
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.Application.Abstractions.Services;
|
||||
using NexusReader.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using NexusReader.Application.Commands.Sync;
|
||||
using NexusReader.Infrastructure.Handlers;
|
||||
using MediatR;
|
||||
using NexusReader.Application.Security.Authorization;
|
||||
|
||||
namespace NexusReader.Infrastructure;
|
||||
|
||||
@@ -18,54 +23,73 @@ public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
|
||||
|
||||
// Register DB Context Factory for multi-threaded/asynchronous safety in Blazor
|
||||
var pgConnectionString = configuration.GetConnectionString("PostgresConnection");
|
||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||
{
|
||||
services.AddDbContextFactory<AppDbContext>(options =>
|
||||
{
|
||||
if (connectionString.Contains("Host="))
|
||||
{
|
||||
options.UseNpgsql(connectionString, o => o.UseVector());
|
||||
options.UseNpgsql(pgConnectionString, x => x.UseVector()));
|
||||
}
|
||||
else
|
||||
{
|
||||
options.UseSqlite(connectionString);
|
||||
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
|
||||
{
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
|
||||
ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests") || ex.Message.Contains("quota")),
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
MaxRetryAttempts = aiSettings.RetryAttempts,
|
||||
Delay = TimeSpan.FromSeconds(2)
|
||||
});
|
||||
});
|
||||
|
||||
// Register Scoped Context for traditional usage
|
||||
services.AddDbContext<AppDbContext>(options =>
|
||||
services.AddChatClient(new GeminiChatClient(new GeminiClientOptions
|
||||
{
|
||||
if (connectionString.Contains("Host="))
|
||||
{
|
||||
options.UseNpgsql(connectionString, o => o.UseVector());
|
||||
}
|
||||
else
|
||||
{
|
||||
options.UseSqlite(connectionString);
|
||||
}
|
||||
});
|
||||
ApiKey = aiSettings.ApiKey,
|
||||
ModelId = aiSettings.Model
|
||||
}));
|
||||
|
||||
// Identity Configuration
|
||||
services.AddAuthorization(options =>
|
||||
services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions
|
||||
{
|
||||
options.AddPolicy("TokenLimitPolicy", policy =>
|
||||
policy.Requirements.Add(new TokenLimitRequirement()));
|
||||
});
|
||||
ApiKey = aiSettings.ApiKey,
|
||||
ModelId = aiSettings.EmbeddingModel ?? "text-embedding-004"
|
||||
}));
|
||||
|
||||
services.AddScoped<IAuthorizationHandler, TokenLimitHandler>();
|
||||
|
||||
// Services
|
||||
services.AddScoped<IEpubReader, EpubReaderService>();
|
||||
services.AddScoped<IEpubMetadataExtractor, EpubMetadataExtractor>();
|
||||
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
||||
services.AddScoped<IBillingService, BillingService>();
|
||||
services.AddSingleton<PromptRegistry>();
|
||||
services.AddTransient<IEpubReader, EpubReaderService>();
|
||||
services.AddTransient<IEpubMetadataExtractor, EpubMetadataExtractor>();
|
||||
|
||||
// Handlers (MediatR)
|
||||
services.AddScoped<IRequestHandler<UpdateReadingProgressCommand, FluentResults.Result>, UpdateReadingProgressCommandHandler>();
|
||||
services.AddAuthorizationCore(options =>
|
||||
{
|
||||
options.AddPolicy("ProUser", policy => policy.Requirements.Add(new ProUserRequirement()));
|
||||
});
|
||||
|
||||
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
|
||||
|
||||
services.AddScoped<IInfrastructureMarker, InfrastructureMarker>();
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
if (File.Exists(checkPath1)) { fullPath = checkPath1; break; }
|
||||
@@ -215,6 +215,7 @@ public class EpubReaderService : IEpubReader
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Metadata extraction moved to EpubMetadataExtractor
|
||||
}
|
||||
|
||||
public class EpubMetadataExtractor : IEpubMetadataExtractor
|
||||
|
||||
@@ -1,139 +1,149 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.Application.Queries.Reader
|
||||
@inject IEpubMetadataExtractor MetadataExtractor
|
||||
@inject ILogger<BookIngestionModal> Logger
|
||||
@implements IAsyncDisposable
|
||||
@inject IEpubMetadataExtractor EpubMetadataExtractor
|
||||
|
||||
<div class="ingestion-modal @(IsVisible ? "visible" : "")" role="dialog" aria-labelledby="modal-title" aria-modal="true">
|
||||
<div class="modal-overlay" @onclick="CloseModal"></div>
|
||||
<div class="modal-container">
|
||||
<header class="modal-header">
|
||||
<NexusTypography Type="h2" id="modal-title">Dodaj nową książkę</NexusTypography>
|
||||
<NexusButton Variant="ghost" OnClick="CloseModal" aria-label="Zamknij">
|
||||
<NexusIcon Name="close" Size="20" />
|
||||
</NexusButton>
|
||||
</header>
|
||||
|
||||
<section class="modal-content">
|
||||
@if (Metadata == null && !IsParsing)
|
||||
@if (IsOpen)
|
||||
{
|
||||
<div class="upload-zone @(IsDragging ? "dragging" : "")"
|
||||
@ondragenter="HandleDragEnter"
|
||||
@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 class="modal-backdrop" @onclick="CloseModal">
|
||||
<div class="modal-content glass-panel" @onclick:stopPropagation>
|
||||
<div class="modal-header">
|
||||
<h2>Add New Book</h2>
|
||||
<button class="close-btn" @onclick="CloseModal">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else if (IsParsing)
|
||||
{
|
||||
<div class="parsing-state">
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="parsing-state shimmer" style="@(IsParsing ? "display:flex;" : "display:none;")">
|
||||
<div class="shimmer-content">
|
||||
<div class="spinner"></div>
|
||||
<NexusTypography Type="body">Analizowanie metadanych pliku...</NexusTypography>
|
||||
<p>Scanning metadata...</p>
|
||||
</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 class="metadata-info">
|
||||
<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">
|
||||
<NexusButton Variant="primary" OnClick="ConfirmIngestion">Dodaj do biblioteki</NexusButton>
|
||||
<NexusButton Variant="secondary" OnClick="Reset">Wybierz inny plik</NexusButton>
|
||||
<button class="btn btn-primary">Confirm & Upload</button>
|
||||
<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>
|
||||
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="error-message">
|
||||
@ErrorMessage
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the modal is visible.
|
||||
/// Gets or sets a value indicating whether the modal is open.
|
||||
/// </summary>
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
[Parameter]
|
||||
public bool IsOpen { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Event callback triggered when the modal is closed.
|
||||
/// Event triggered when the IsOpen state changes.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
[Parameter]
|
||||
public EventCallback<bool> IsOpenChanged { 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; }
|
||||
private bool _isDragging;
|
||||
private bool IsParsing { 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()
|
||||
{
|
||||
Metadata = null;
|
||||
await OnClose.InvokeAsync();
|
||||
IsOpen = false;
|
||||
Reset();
|
||||
await IsOpenChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
private void HandleDragEnter() => IsDragging = true;
|
||||
private void HandleDragLeave() => IsDragging = false;
|
||||
|
||||
private async Task HandleDrop(DragEventArgs e)
|
||||
private void Reset()
|
||||
{
|
||||
IsDragging = false;
|
||||
// JS interop would be needed for actual drag-drop file access in Blazor WASM
|
||||
// This is a placeholder for the interaction pattern
|
||||
IsParsing = false;
|
||||
Metadata = null;
|
||||
ErrorMessage = null;
|
||||
_isDragging = false;
|
||||
}
|
||||
|
||||
private void OnDragEnter() => _isDragging = true;
|
||||
private void OnDragLeave() => _isDragging = false;
|
||||
|
||||
private async Task HandleFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
_isDragging = false;
|
||||
var file = e.File;
|
||||
|
||||
if (file == null) return;
|
||||
|
||||
if (!file.Name.EndsWith(".epub", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ErrorMessage = "Only .epub files are supported.";
|
||||
return;
|
||||
}
|
||||
|
||||
ErrorMessage = null;
|
||||
IsParsing = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// Requirement: Extract metadata locally before uploading
|
||||
// We limit the size for extraction safety
|
||||
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB
|
||||
using var stream = file.OpenReadStream(MaxFileSize);
|
||||
|
||||
// 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();
|
||||
await stream.CopyToAsync(memoryStream);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
var result = await EpubMetadataExtractor.ExtractMetadataAsync(memoryStream);
|
||||
var result = await MetadataExtractor.ExtractMetadataAsync(memoryStream);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
Metadata = result.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = result.Errors.FirstOrDefault()?.Message ?? "Failed to parse EPUB.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log or handle error
|
||||
Console.WriteLine($"Metadata extraction failed: {ex.Message}");
|
||||
Logger.LogError(ex, "Error uploading EPUB");
|
||||
ErrorMessage = $"An unexpected error occurred: {ex.Message} \n {ex.StackTrace}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -141,22 +151,6 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConfirmIngestion()
|
||||
{
|
||||
if (Metadata != null)
|
||||
{
|
||||
await OnBookAdded.InvokeAsync(Metadata);
|
||||
await CloseModal();
|
||||
}
|
||||
}
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
Metadata = null;
|
||||
IsParsing = false;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// Cleanup if necessary
|
||||
|
||||
@@ -1,167 +1,272 @@
|
||||
.ingestion-modal {
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
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;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.ingestion-modal.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
.drop-zone-content svg {
|
||||
color: var(--nexus-accent, #00ffaa);
|
||||
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;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
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;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.file-input-label:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.file-input-label input {
|
||||
display: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 40px 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(var(--accent-primary-rgb), 0.1);
|
||||
border-top-color: var(--accent-primary);
|
||||
border: 3px solid rgba(0, 255, 153, 0.1);
|
||||
border-top-color: var(--nexus-neon, #00ffaa);
|
||||
border-radius: 50%;
|
||||
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 {
|
||||
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) {
|
||||
.ingestion-modal,
|
||||
.modal-container,
|
||||
.file-input-label,
|
||||
.upload-zone {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.modal-backdrop,
|
||||
.shimmer::before,
|
||||
.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);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo("/");
|
||||
NavigationManager.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private UserProfile? _profile;
|
||||
private UserProfileDto? _profile;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private UserProfile? _profile;
|
||||
private UserProfileDto? _profile;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
|
||||
@@ -1,108 +1,118 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Constants;
|
||||
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
_storageService = storageService;
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
{
|
||||
_cachedState = null;
|
||||
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||
}
|
||||
|
||||
private AuthenticationState? _cachedState;
|
||||
|
||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_cachedState != null) return _cachedState;
|
||||
|
||||
try
|
||||
{
|
||||
var tokenResult = await _storageService.GetSecureString(StorageKeys.AuthToken);
|
||||
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
||||
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 tenantResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
||||
var tenantIdResult = 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[]
|
||||
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
|
||||
{
|
||||
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 = new AuthenticationState(new ClaimsPrincipal(identity));
|
||||
_cachedState = CreateState(
|
||||
emailResult.Value,
|
||||
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
|
||||
"OpaqueBearer",
|
||||
rolesResult.IsSuccess ? rolesResult.Value! : "");
|
||||
return _cachedState;
|
||||
}
|
||||
catch
|
||||
}
|
||||
|
||||
// 2. Try Cookie-based auth indicators
|
||||
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 (Exception)
|
||||
{
|
||||
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.Email, email),
|
||||
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 user = new ClaimsPrincipal(identity);
|
||||
_cachedState = new AuthenticationState(user);
|
||||
var authState = Task.FromResult(_cachedState);
|
||||
NotifyAuthenticationStateChanged(authState);
|
||||
var identity = new ClaimsIdentity(claims, authType);
|
||||
return new AuthenticationState(new ClaimsPrincipal(identity));
|
||||
}
|
||||
|
||||
public void NotifyUserAuthentication(string email, string tenantId, string rolesStr = "")
|
||||
{
|
||||
_cachedState = CreateState(email, tenantId, "OpaqueBearer", rolesStr);
|
||||
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
||||
}
|
||||
|
||||
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;
|
||||
var guest = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,3 +16,6 @@
|
||||
@using NexusReader.UI.Shared.Components.Organisms
|
||||
@using NexusReader.UI.Shared.Services
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using NexusReader.Application.Abstractions.Services
|
||||
@using NexusReader.Application.DTOs.User
|
||||
@using NexusReader.Application.Queries.Reader
|
||||
|
||||
@@ -1,52 +1,66 @@
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using NexusReader.Web.Client;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Web.Client.Services;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using NexusReader.Web.Client.Handlers;
|
||||
using NexusReader.UI.Shared.Services;
|
||||
using NexusReader.Application;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using NexusReader.Data.Persistence;
|
||||
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
// --- Identity & Auth ---
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddScoped<AuthenticationStateProvider, NexusAuthenticationStateProvider>();
|
||||
|
||||
// --- Storage & Platform ---
|
||||
builder.Services.AddScoped<INativeStorageService, WebStorageService>();
|
||||
// Platform & UI Services
|
||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||
|
||||
// --- 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<INativeStorageService, 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<IQuizStateService, QuizStateService>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
|
||||
// --- Application Layer (Mappings etc) ---
|
||||
builder.Services.AddApplication();
|
||||
// Identity & Auth Services
|
||||
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 ---
|
||||
builder.Services.AddTransient<AuthenticationHeaderHandler>();
|
||||
// AI & Content Services
|
||||
builder.Services.AddScoped<IKnowledgeService, WasmKnowledgeService>();
|
||||
|
||||
builder.Services.AddTransient<NexusReader.Web.Client.Handlers.AuthenticationHeaderHandler>();
|
||||
builder.Services.AddHttpClient("NexusAPI", client =>
|
||||
{
|
||||
client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
|
||||
})
|
||||
.AddHttpMessageHandler<AuthenticationHeaderHandler>();
|
||||
}).AddHttpMessageHandler<NexusReader.Web.Client.Handlers.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"));
|
||||
|
||||
// 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();
|
||||
|
||||
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 NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Queries.Reader;
|
||||
using VersOne.Epub;
|
||||
|
||||
namespace NexusReader.Web.Client.Services;
|
||||
|
||||
@@ -19,19 +18,24 @@ public class WasmEpubReader : IEpubReader
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync($"api/epub/content?index={chapterIndex}&userId={userId}");
|
||||
var response = await _httpClient.GetAsync($"/api/epub/{chapterIndex}");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<ReaderPageViewModel>();
|
||||
return result != null ? Result.Ok(result) : Result.Fail("Failed to deserialize reader page.");
|
||||
var viewModel = await response.Content.ReadFromJsonAsync<ReaderPageViewModel>();
|
||||
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)
|
||||
{
|
||||
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
|
||||
@@ -40,7 +44,7 @@ public class WasmEpubMetadataExtractor : IEpubMetadataExtractor
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bookRef = await EpubReader.OpenBookAsync(epubStream);
|
||||
using var bookRef = await VersOne.Epub.EpubReader.OpenBookAsync(epubStream);
|
||||
var title = bookRef.Title ?? "Unknown Title";
|
||||
var author = bookRef.Author ?? "Unknown Author";
|
||||
byte[]? cover = await bookRef.ReadCoverAsync();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 Server‑Side Blazor components
|
||||
builder.Services.AddServerSideBlazor()
|
||||
.AddCircuitOptions(options =>
|
||||
{
|
||||
options.DetailedErrors = true;
|
||||
});
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
|
||||
builder.Services.AddHttpClient("NexusAPI", 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">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</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" />
|
||||
<PackageReference Include="xunit" Version="2.9.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\NexusReader.Application\NexusReader.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using NexusReader.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
@@ -5,24 +8,18 @@ namespace NexusReader.Application.Tests.Services;
|
||||
|
||||
public class EpubMetadataExtractorTests
|
||||
{
|
||||
private readonly EpubMetadataExtractor _sut;
|
||||
|
||||
public EpubMetadataExtractorTests()
|
||||
{
|
||||
_sut = new EpubMetadataExtractor();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractMetadataAsync_WithInvalidStream_ReturnsFailure()
|
||||
{
|
||||
// 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
|
||||
var result = await _sut.ExtractMetadataAsync(invalidStream);
|
||||
var result = await extractor.ExtractMetadataAsync(stream);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsFailed);
|
||||
Assert.Contains("Failed to extract EPUB metadata", result.Errors[0].Message);
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Errors.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user