@@ -126,8 +126,8 @@
protected override async Task OnInitializedAsync()
{
- _allowRegistration = Configuration.GetValue
("Features:AllowRegistration") ?? true;
- _allowPasswordReset = Configuration.GetValue("Features:AllowPasswordReset") ?? true;
+ _allowRegistration = FeatureSettings.AllowRegistration;
+ _allowPasswordReset = FeatureSettings.AllowPasswordReset;
if (!string.IsNullOrEmpty(ErrorCode))
{
diff --git a/src/NexusReader.UI.Shared/Pages/Account/Register.razor b/src/NexusReader.UI.Shared/Pages/Account/Register.razor
index 95534c8..b4b18bb 100644
--- a/src/NexusReader.UI.Shared/Pages/Account/Register.razor
+++ b/src/NexusReader.UI.Shared/Pages/Account/Register.razor
@@ -7,7 +7,7 @@
@inject IIdentityService IdentityService
@inject NavigationManager NavigationManager
@inject IJSRuntime JS
-@inject IConfiguration Configuration
+@inject FeatureSettings FeatureSettings
@@ -84,7 +84,7 @@
protected override void OnInitialized()
{
- var allowRegistration = Configuration.GetValue
("Features:AllowRegistration") ?? true;
+ var allowRegistration = FeatureSettings.AllowRegistration;
if (!allowRegistration)
{
NavigationManager.NavigateTo("/account/login?error=RegistrationDisabled", replace: true);
diff --git a/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor
index fde0f5d..8e2efbb 100644
--- a/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor
+++ b/src/NexusReader.UI.Shared/Pages/SerilogDemo.razor
@@ -151,6 +151,11 @@ else
catch (Exception ex)
{
Logger.LogError(ex, "Simulated runtime JS Exception triggered and captured in Blazor UI");
+ try
+ {
+ await JSRuntime.InvokeVoidAsync("console.error", $"Simulated JS Exception: {ex.Message}");
+ }
+ catch { }
}
}
}
diff --git a/src/NexusReader.UI.Shared/Services/FeatureSettings.cs b/src/NexusReader.UI.Shared/Services/FeatureSettings.cs
new file mode 100644
index 0000000..8a79ba9
--- /dev/null
+++ b/src/NexusReader.UI.Shared/Services/FeatureSettings.cs
@@ -0,0 +1,11 @@
+namespace NexusReader.UI.Shared.Services;
+
+///
+/// Strongly-typed feature settings for the client UI layer.
+/// Used to decouple the UI from raw IConfiguration to prevent exposure of sensitive settings.
+///
+public class FeatureSettings
+{
+ public bool AllowRegistration { get; set; } = true;
+ public bool AllowPasswordReset { get; set; } = true;
+}
diff --git a/src/NexusReader.UI.Shared/Services/ReaderStateService.cs b/src/NexusReader.UI.Shared/Services/ReaderStateService.cs
index 4906759..3759fd6 100644
--- a/src/NexusReader.UI.Shared/Services/ReaderStateService.cs
+++ b/src/NexusReader.UI.Shared/Services/ReaderStateService.cs
@@ -4,6 +4,8 @@ namespace NexusReader.UI.Shared.Services;
///
/// Thread-safe implementation of IReaderStateService.
+/// Thread safety is ensured via lock-guarded property getters/setters.
+/// UI updates originating from the JS event loop (via JSInvokable) are synchronized at Blazor's InvokeAsync(StateHasChanged) render boundary.
///
public sealed class ReaderStateService : IReaderStateService
{
diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs
index 68a7479..bac3723 100644
--- a/src/NexusReader.Web.Client/Program.cs
+++ b/src/NexusReader.Web.Client/Program.cs
@@ -18,6 +18,9 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+// Feature settings (avoiding direct raw IConfiguration injection in client pages)
+var featureSettings = builder.Configuration.GetSection("Features").Get() ?? new FeatureSettings();
+builder.Services.AddSingleton(featureSettings);
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/src/NexusReader.Web.Client/Services/WasmEpubService.cs b/src/NexusReader.Web.Client/Services/WasmEpubService.cs
index 252463f..fcd0b76 100644
--- a/src/NexusReader.Web.Client/Services/WasmEpubService.cs
+++ b/src/NexusReader.Web.Client/Services/WasmEpubService.cs
@@ -37,7 +37,29 @@ public class WasmEpubReader : IEpubReader
return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex));
}
}
- // Metadata extraction moved to WasmEpubMetadataExtractor
+ public async Task> GetEpubResourceAsync(
+ Guid ebookId,
+ string resourcePath,
+ string? userId = null,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var response = await _httpClient.GetAsync($"/api/epub/{ebookId}/resource?path={Uri.EscapeDataString(resourcePath)}", cancellationToken);
+ if (response.IsSuccessStatusCode)
+ {
+ var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
+ return Result.Ok(bytes);
+ }
+
+ var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
+ return Result.Fail($"Server error fetching EPUB resource ({response.StatusCode}): {errorBody}");
+ }
+ catch (Exception ex)
+ {
+ return Result.Fail(new Error($"Network error fetching EPUB resource: {ex.Message}").CausedBy(ex));
+ }
+ }
}
public class WasmEpubMetadataExtractor : IEpubMetadataExtractor
diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs
index 033b029..38c8006 100644
--- a/src/NexusReader.Web/Program.cs
+++ b/src/NexusReader.Web/Program.cs
@@ -48,6 +48,9 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+// Feature settings (avoiding direct raw IConfiguration injection in client pages)
+var featureSettings = builder.Configuration.GetSection("Features").Get() ?? new FeatureSettings();
+builder.Services.AddSingleton(featureSettings);
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -297,6 +300,50 @@ app.MapGet("/api/epub/{ebookId:guid}/{index:int}", async (Guid ebookId, int inde
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
+// API endpoint for WASM client/browser to fetch EPUB static resources (images, etc.)
+app.MapGet("/api/epub/{ebookId:guid}/resource", async (Guid ebookId, string path, IEpubReader epubService, ClaimsPrincipal user, HttpContext httpContext, CancellationToken cancellationToken) =>
+{
+ if (string.IsNullOrEmpty(path))
+ {
+ return Results.BadRequest("Path parameter is required.");
+ }
+
+ var decodedPath = Uri.UnescapeDataString(path);
+ if (decodedPath.Contains("..") || decodedPath.Contains(":") || decodedPath.StartsWith("/") || decodedPath.StartsWith("\\"))
+ {
+ return Results.BadRequest("Invalid resource path.");
+ }
+
+ var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
+ var result = await epubService.GetEpubResourceAsync(ebookId, decodedPath, userId, cancellationToken);
+
+ if (result.IsSuccess)
+ {
+ // Serve with client-side caching to avoid redundant roundtrips on chapter navigation
+ httpContext.Response.Headers.CacheControl = "public, max-age=86400";
+
+ var ext = Path.GetExtension(decodedPath).ToLowerInvariant();
+ var contentType = ext switch
+ {
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".png" => "image/png",
+ ".gif" => "image/gif",
+ ".svg" => "image/svg+xml",
+ ".webp" => "image/webp",
+ ".css" => "text/css",
+ ".otf" => "font/otf",
+ ".ttf" => "font/ttf",
+ ".woff" => "font/woff",
+ ".woff2" => "font/woff2",
+ _ => "application/octet-stream"
+ };
+ return Results.File(result.Value, contentType);
+ }
+
+ var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Resource not found";
+ return Results.NotFound(errorMsg);
+}).RequireAuthorization();
+
var knowledgeApi = app.MapGroup("/api/knowledge")
.RequireAuthorization("HasAvailableTokens")
.DisableAntiforgery();
diff --git a/tests/NexusReader.Application.Tests/Services/EpubReaderServiceTests.cs b/tests/NexusReader.Application.Tests/Services/EpubReaderServiceTests.cs
new file mode 100644
index 0000000..81609bf
--- /dev/null
+++ b/tests/NexusReader.Application.Tests/Services/EpubReaderServiceTests.cs
@@ -0,0 +1,275 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Moq;
+using NexusReader.Data.Persistence;
+using NexusReader.Domain.Entities;
+using NexusReader.Application.Queries.Reader;
+using NexusReader.Infrastructure.Services;
+using Xunit;
+
+namespace NexusReader.Application.Tests.Services;
+
+public class EpubReaderServiceTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions _contextOptions;
+ private readonly Mock> _dbContextFactoryMock;
+ private readonly Mock> _loggerMock;
+
+ public EpubReaderServiceTests()
+ {
+ _connection = new SqliteConnection("DataSource=:memory:");
+ _connection.Open();
+
+ _contextOptions = new DbContextOptionsBuilder()
+ .UseSqlite(_connection)
+ .Options;
+
+ // Seed initial database schema
+ using var context = new AppDbContext(_contextOptions);
+ context.Database.EnsureCreated();
+
+ _dbContextFactoryMock = new Mock>();
+ _dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny()))
+ .ReturnsAsync(() => new AppDbContext(_contextOptions));
+ _dbContextFactoryMock.Setup(f => f.CreateDbContext())
+ .Returns(() => new AppDbContext(_contextOptions));
+
+ _loggerMock = new Mock>();
+ }
+
+ [Fact]
+ public async Task GetEpubContentAsync_RewritesImageUrlsAndExtractsImages()
+ {
+ // Arrange
+ var ebookId = Guid.NewGuid();
+ var userId = "test-user-id";
+
+ using (var context = new AppDbContext(_contextOptions))
+ {
+ var user = new NexusUser
+ {
+ Id = userId,
+ UserName = "testuser",
+ Email = "test@nexus.com",
+ TenantId = "tenant-123",
+ SubscriptionPlanId = 1
+ };
+ context.Users.Add(user);
+
+ var author = new Author { Id = 10, Name = "Giorgio Vasari" };
+ context.Authors.Add(author);
+
+ var ebook = new Ebook
+ {
+ Id = ebookId,
+ UserId = userId,
+ Title = "Test Book",
+ AuthorId = author.Id,
+ FilePath = "assets/book.epub",
+ AddedDate = DateTime.UtcNow,
+ LastReadDate = DateTime.UtcNow,
+ Progress = 0,
+ LastChapter = "Introduction"
+ };
+ context.Ebooks.Add(ebook);
+ await context.SaveChangesAsync();
+ }
+
+ var service = new EpubReaderService(_dbContextFactoryMock.Object, _loggerMock.Object);
+
+ // Act
+ var result = await service.GetEpubContentAsync(ebookId, 0, userId);
+
+ // Assert
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Should().NotBeNull();
+ result.Value.Blocks.Should().NotBeEmpty();
+
+ // Check that any img tags extracted are preserved and rewritten
+ var hasImages = false;
+ foreach (var block in result.Value.Blocks)
+ {
+ if (block is TextSegmentBlock textBlock && textBlock.Content.Contains("
";
+
+ // Act
+ var result = (string)method.Invoke(null, new object[] { input });
+
+ // Assert
+ result.Should().NotContain("onerror");
+ result.Should().NotContain("onload");
+ result.Should().NotContain("style");
+ result.Should().NotContain("class");
+ result.Should().Contain("src=\"images/cover.jpg\"");
+ result.Should().Contain("alt=\"Cover Image\"");
+ }
+
+ [Fact]
+ public void RewriteImageUrls_BlocksJavaScriptScheme()
+ {
+ // Arrange
+ var method = typeof(EpubReaderService).GetMethod("RewriteImageUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
+ method.Should().NotBeNull();
+
+ var input = "
";
+ var ebookId = Guid.NewGuid();
+
+ // Act
+ var result = (string)method.Invoke(null, new object[] { input, ebookId, "OEBPS/chapter1.xhtml" });
+
+ // Assert
+ result.Should().NotContain("javascript:alert(1)");
+ }
+
+ [Fact]
+ public async Task GetEpubResourceAsync_RejectsInvalidResourcePaths()
+ {
+ // Arrange
+ var ebookId = Guid.NewGuid();
+ var userId = "test-user-id";
+
+ using (var context = new AppDbContext(_contextOptions))
+ {
+ var user = new NexusUser
+ {
+ Id = userId,
+ UserName = "testuser",
+ Email = "test@nexus.com",
+ TenantId = "tenant-123",
+ SubscriptionPlanId = 1
+ };
+ context.Users.Add(user);
+
+ var author = new Author { Id = 10, Name = "Giorgio Vasari" };
+ context.Authors.Add(author);
+
+ var ebook = new Ebook
+ {
+ Id = ebookId,
+ UserId = userId,
+ Title = "Test Book",
+ AuthorId = author.Id,
+ FilePath = "assets/book.epub",
+ AddedDate = DateTime.UtcNow,
+ LastReadDate = DateTime.UtcNow,
+ Progress = 0,
+ LastChapter = "Introduction"
+ };
+ context.Ebooks.Add(ebook);
+ await context.SaveChangesAsync();
+ }
+
+ var service = new EpubReaderService(_dbContextFactoryMock.Object, _loggerMock.Object);
+
+ // Act
+ var traversalResult = await service.GetEpubResourceAsync(ebookId, "../../appsettings.json", userId);
+ var colonResult = await service.GetEpubResourceAsync(ebookId, "C:\\windows\\win.ini", userId);
+
+ // Assert
+ traversalResult.IsSuccess.Should().BeFalse();
+ traversalResult.Errors.First().Message.Should().Contain("Invalid resource path");
+
+ colonResult.IsSuccess.Should().BeFalse();
+ colonResult.Errors.First().Message.Should().Contain("Invalid resource path");
+ }
+
+ public void Dispose()
+ {
+ _connection.Close();
+ _connection.Dispose();
+ }
+}