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
|
### 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
@@ -29,4 +29,4 @@ Thumbs.db
|
|||||||
*.epub
|
*.epub
|
||||||
|
|
||||||
.fake
|
.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
|
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
|
||||||
|
|||||||
+7
-5
@@ -1,14 +1,16 @@
|
|||||||
<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.Mobile/NexusReader.Infrastructure.Mobile.csproj" />
|
|
||||||
<Project Path="src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj" />
|
<Project Path="src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj" />
|
||||||
<Project Path="src/NexusReader.Maui/NexusReader.Maui.csproj" />
|
<Project Path="src/NexusReader.Infrastructure.Mobile/NexusReader.Infrastructure.Mobile.csproj" />
|
||||||
<Project Path="src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj" />
|
|
||||||
<Project Path="src/NexusReader.Web.Client/NexusReader.Web.Client.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>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj" />
|
<Project Path="tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj" />
|
||||||
|
|||||||
+2
-2
@@ -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; }
|
||||||
|
|
||||||
|
/// <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; }
|
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 =>
|
services.AddDbContextFactory<AppDbContext>(options =>
|
||||||
{
|
options.UseNpgsql(pgConnectionString, x => x.UseVector()));
|
||||||
if (connectionString.Contains("Host="))
|
|
||||||
{
|
|
||||||
options.UseNpgsql(connectionString, o => o.UseVector());
|
|
||||||
}
|
}
|
||||||
else
|
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.AddChatClient(new GeminiChatClient(new GeminiClientOptions
|
||||||
services.AddDbContext<AppDbContext>(options =>
|
|
||||||
{
|
{
|
||||||
if (connectionString.Contains("Host="))
|
ApiKey = aiSettings.ApiKey,
|
||||||
{
|
ModelId = aiSettings.Model
|
||||||
options.UseNpgsql(connectionString, o => o.UseVector());
|
}));
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
options.UseSqlite(connectionString);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Identity Configuration
|
services.AddEmbeddingGenerator(new GeminiEmbeddingGenerator(new GeminiClientOptions
|
||||||
services.AddAuthorization(options =>
|
|
||||||
{
|
{
|
||||||
options.AddPolicy("TokenLimitPolicy", policy =>
|
ApiKey = aiSettings.ApiKey,
|
||||||
policy.Requirements.Add(new TokenLimitRequirement()));
|
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<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>
|
||||||
|
|
||||||
<section class="modal-content">
|
|
||||||
@if (Metadata == null && !IsParsing)
|
|
||||||
{
|
|
||||||
<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>
|
</div>
|
||||||
}
|
|
||||||
else if (IsParsing)
|
<div class="modal-body">
|
||||||
{
|
<div class="parsing-state shimmer" style="@(IsParsing ? "display:flex;" : "display:none;")">
|
||||||
<div class="parsing-state">
|
<div class="shimmer-content">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<NexusTypography Type="body">Analizowanie metadanych pliku...</NexusTypography>
|
<p>Scanning metadata...</p>
|
||||||
</div>
|
</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">
|
|
||||||
<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>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
|
||||||
</div>
|
</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>
|
||||||
|
}
|
||||||
|
|
||||||
@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>
|
/// <summary>
|
||||||
/// Event callback triggered when the modal is closed.
|
/// Event triggered when the IsOpen state changes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Parameter] public EventCallback OnClose { get; set; }
|
[Parameter]
|
||||||
|
public EventCallback<bool> IsOpenChanged { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
private bool _isDragging;
|
||||||
/// 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 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()
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
if (_cachedState != null) return _cachedState;
|
if (_cachedState != null) return _cachedState;
|
||||||
|
|
||||||
try
|
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
||||||
{
|
|
||||||
var tokenResult = await _storageService.GetSecureString(StorageKeys.AuthToken);
|
|
||||||
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 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 rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
|
||||||
|
|
||||||
var email = emailResult.IsSuccess ? emailResult.Value : "unknown";
|
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
|
||||||
var tenantId = tenantResult.IsSuccess ? tenantResult.Value : "default";
|
|
||||||
var roles = rolesResult.IsSuccess ? rolesResult.Value : "";
|
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(new[]
|
|
||||||
{
|
{
|
||||||
new Claim(ClaimTypes.Name, email),
|
_cachedState = CreateState(
|
||||||
new Claim("TenantId", tenantId)
|
emailResult.Value,
|
||||||
}, "api");
|
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
|
||||||
|
"OpaqueBearer",
|
||||||
if (!string.IsNullOrEmpty(roles))
|
rolesResult.IsSuccess ? rolesResult.Value! : "");
|
||||||
{
|
|
||||||
foreach (var role in roles.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
|
||||||
{
|
|
||||||
identity.AddClaim(new Claim(ClaimTypes.Role, role.Trim()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_cachedState = new AuthenticationState(new ClaimsPrincipal(identity));
|
|
||||||
return _cachedState;
|
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()));
|
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 user = new ClaimsPrincipal(identity);
|
var identity = new ClaimsIdentity(claims, authType);
|
||||||
_cachedState = new AuthenticationState(user);
|
return new AuthenticationState(new ClaimsPrincipal(identity));
|
||||||
var authState = Task.FromResult(_cachedState);
|
}
|
||||||
NotifyAuthenticationStateChanged(authState);
|
|
||||||
|
public void NotifyUserAuthentication(string email, string tenantId, string rolesStr = "")
|
||||||
|
{
|
||||||
|
_cachedState = CreateState(email, tenantId, "OpaqueBearer", rolesStr);
|
||||||
|
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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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">
|
<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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user