feat(infra): Docker-compose configuration and environment-specific security guards for Beta deployment to Test environment #56
@@ -20,4 +20,17 @@ public interface IEpubReader
|
|||||||
int chapterIndex,
|
int chapterIndex,
|
||||||
string? userId = null,
|
string? userId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a resource (like an image) from the EPUB as a byte array.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ebookId">The unique ID of the ebook to read.</param>
|
||||||
|
/// <param name="resourcePath">The path of the resource within the EPUB archive.</param>
|
||||||
|
/// <param name="userId">The authenticated user's ID (used for tenant isolation).</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
Task<Result<byte[]>> GetEpubResourceAsync(
|
||||||
|
Guid ebookId,
|
||||||
|
string resourcePath,
|
||||||
|
string? userId = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ public class EpubReaderService : IEpubReader
|
|||||||
private readonly ILogger<EpubReaderService> _logger;
|
private readonly ILogger<EpubReaderService> _logger;
|
||||||
private const int WordThreshold = 1000;
|
private const int WordThreshold = 1000;
|
||||||
|
|
||||||
|
private static readonly Regex ImageTagRegex = new(@"<img\b(?<before>[^>]*?\bsrc=[""'])(?<src>[^""']*?)(?<after>[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex BodyMatchRegex = new(@"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex ParagraphMatchRegex = new(@"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>|<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex StyleScriptRegex = new(@"<(style|script)\b[^>]*>.*?</\1>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex WhitelistTagsRegex = new(@"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr|img)\b)[^>]+>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex StripAttributesRegex = new(@"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex ImgTagSanitizerRegex = new(@"<img\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex SrcAttributeRegex = new(@"\bsrc=[""'](?<src>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
private static readonly Regex AltAttributeRegex = new(@"\balt=[""'](?<alt>[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
public EpubReaderService(
|
public EpubReaderService(
|
||||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||||
ILogger<EpubReaderService> logger)
|
ILogger<EpubReaderService> logger)
|
||||||
@@ -80,6 +90,9 @@ public class EpubReaderService : IEpubReader
|
|||||||
|
|
||||||
var chapterContent = await chapterRef.ReadContentAsTextAsync();
|
var chapterContent = await chapterRef.ReadContentAsTextAsync();
|
||||||
|
|
||||||
|
// Rewrite relative image src URLs to use the server-side API endpoint
|
||||||
|
chapterContent = RewriteImageUrls(chapterContent, ebookId, chapterRef.FilePath);
|
||||||
|
|
||||||
// 3. Build content blocks
|
// 3. Build content blocks
|
||||||
var blocks = new List<ContentBlock>();
|
var blocks = new List<ContentBlock>();
|
||||||
int totalWordCount = 0;
|
int totalWordCount = 0;
|
||||||
@@ -142,13 +155,150 @@ public class EpubReaderService : IEpubReader
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Result<byte[]>> GetEpubResourceAsync(
|
||||||
|
Guid ebookId,
|
||||||
|
string resourcePath,
|
||||||
|
string? userId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
var ebook = await context.Ebooks
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
e => e.Id == ebookId && (userId == null || e.UserId == userId),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (ebook == null)
|
||||||
|
{
|
||||||
|
return Result.Fail($"Ebook '{ebookId}' not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullPath = ResolvePath(ebook.FilePath);
|
||||||
|
if (fullPath == null || !File.Exists(fullPath))
|
||||||
|
{
|
||||||
|
return Result.Fail("EPUB file not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var bookRef = await EpubReader.OpenBookAsync(fullPath);
|
||||||
|
|
||||||
|
var decodedPath = System.Net.WebUtility.UrlDecode(resourcePath);
|
||||||
|
if (decodedPath.Contains("..") || decodedPath.Contains(":") || decodedPath.StartsWith("/") || decodedPath.StartsWith("\\"))
|
||||||
|
{
|
||||||
|
return Result.Fail("Invalid resource path.");
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedPath = decodedPath.Replace('\\', '/').TrimStart('/');
|
||||||
|
|
||||||
|
EpubLocalContentFileRef? targetFile = null;
|
||||||
|
if (bookRef.Content?.AllFiles?.Local != null)
|
||||||
|
{
|
||||||
|
foreach (var file in bookRef.Content.AllFiles.Local)
|
||||||
|
{
|
||||||
|
var filePath = file.FilePath?.Replace('\\', '/').TrimStart('/') ?? "";
|
||||||
|
var fileKey = file.Key?.Replace('\\', '/').TrimStart('/') ?? "";
|
||||||
|
if (filePath.Equals(decodedPath, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
fileKey.Equals(decodedPath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
targetFile = file;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetFile != null)
|
||||||
|
{
|
||||||
|
if (targetFile is EpubLocalByteContentFileRef byteFile)
|
||||||
|
{
|
||||||
|
byte[] bytes = await byteFile.ReadContentAsync();
|
||||||
|
return Result.Ok(bytes);
|
||||||
|
}
|
||||||
|
else if (targetFile is EpubLocalTextContentFileRef textFile)
|
||||||
|
{
|
||||||
|
string text = await textFile.ReadContentAsync();
|
||||||
|
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(text);
|
||||||
|
return Result.Ok(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.Fail($"Resource '{resourcePath}' not found in EPUB.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to retrieve EPUB resource '{ResourcePath}' for ebook {EbookId}.", resourcePath, ebookId);
|
||||||
|
return Result.Fail(new Error($"Failed to retrieve EPUB resource: {ex.Message}").CausedBy(ex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RewriteImageUrls(string html, Guid ebookId, string chapterPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(html)) return html;
|
||||||
|
|
||||||
|
return ImageTagRegex.Replace(html, match =>
|
||||||
|
{
|
||||||
|
var rawSrc = match.Groups["src"].Value;
|
||||||
|
|
||||||
|
if (rawSrc.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return ""; // Completely block script execution in image src
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawSrc.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
rawSrc.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
rawSrc.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return match.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedPath = ResolveRelativePath(chapterPath, rawSrc);
|
||||||
|
var rewrittenSrc = $"/api/epub/{ebookId}/resource?path={System.Net.WebUtility.UrlEncode(resolvedPath)}";
|
||||||
|
return $"{match.Groups["before"].Value}{rewrittenSrc}{match.Groups["after"].Value}";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveRelativePath(string basePath, string relativePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(relativePath)) return string.Empty;
|
||||||
|
|
||||||
|
var decodedRelative = System.Net.WebUtility.UrlDecode(relativePath);
|
||||||
|
var baseDir = Path.GetDirectoryName(basePath) ?? "";
|
||||||
|
baseDir = baseDir.Replace('\\', '/');
|
||||||
|
|
||||||
|
var combined = Path.Combine(baseDir, decodedRelative).Replace('\\', '/');
|
||||||
|
var segments = combined.Split('/');
|
||||||
|
var stack = new Stack<string>();
|
||||||
|
|
||||||
|
foreach (var segment in segments)
|
||||||
|
{
|
||||||
|
if (segment == "." || string.IsNullOrEmpty(segment))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (segment == "..")
|
||||||
|
{
|
||||||
|
if (stack.Count > 0)
|
||||||
|
{
|
||||||
|
stack.Pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stack.Push(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join("/", stack.Reverse());
|
||||||
|
}
|
||||||
|
|
||||||
private static List<string> ExtractParagraphs(string html)
|
private static List<string> ExtractParagraphs(string html)
|
||||||
{
|
{
|
||||||
var bodyMatch = Regex.Match(html, @"<body\b[^>]*>(.*?)</body>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
var bodyMatch = BodyMatchRegex.Match(html);
|
||||||
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
|
var content = bodyMatch.Success ? bodyMatch.Groups[1].Value : html;
|
||||||
|
|
||||||
var paragraphs = new List<string>();
|
var paragraphs = new List<string>();
|
||||||
var matches = Regex.Matches(content, @"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?</\1>|<hr\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
var matches = ParagraphMatchRegex.Matches(content);
|
||||||
|
|
||||||
foreach (Match match in matches)
|
foreach (Match match in matches)
|
||||||
{
|
{
|
||||||
@@ -165,9 +315,20 @@ public class EpubReaderService : IEpubReader
|
|||||||
|
|
||||||
private static string SanitizeParagraph(string html)
|
private static string SanitizeParagraph(string html)
|
||||||
{
|
{
|
||||||
var clean = Regex.Replace(html, @"<(style|script)\b[^>]*>.*?</\1>", "", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
var clean = StyleScriptRegex.Replace(html, "");
|
||||||
clean = Regex.Replace(clean, @"<(?!/?(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b)[^>]+>", "", RegexOptions.IgnoreCase);
|
clean = WhitelistTagsRegex.Replace(clean, "");
|
||||||
clean = Regex.Replace(clean, @"<(b|i|strong|em|h[1-6]|p|ul|ol|li|blockquote|pre|code|br|hr)\b[^>]*>", "<$1>", RegexOptions.IgnoreCase);
|
clean = StripAttributesRegex.Replace(clean, "<$1>");
|
||||||
|
|
||||||
|
// Securely sanitize img tags by keeping ONLY src and alt attributes to prevent XSS (onerror, onload, style, etc.)
|
||||||
|
clean = ImgTagSanitizerRegex.Replace(clean, m =>
|
||||||
|
{
|
||||||
|
var srcMatch = SrcAttributeRegex.Match(m.Value);
|
||||||
|
var altMatch = AltAttributeRegex.Match(m.Value);
|
||||||
|
var srcAttr = srcMatch.Success ? $" src=\"{srcMatch.Groups["src"].Value}\"" : "";
|
||||||
|
var altAttr = altMatch.Success ? $" alt=\"{altMatch.Groups["alt"].Value}\"" : "";
|
||||||
|
return $"<img{srcAttr}{altAttr} />";
|
||||||
|
});
|
||||||
|
|
||||||
clean = System.Net.WebUtility.HtmlDecode(clean);
|
clean = System.Net.WebUtility.HtmlDecode(clean);
|
||||||
return clean.Trim();
|
return clean.Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ public static class MauiProgram
|
|||||||
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("NexusAPI"));
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
|
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
||||||
|
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||||
|
builder.Services.AddSingleton(featureSettings);
|
||||||
|
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
@inject IIdentityService IdentityService
|
@inject IIdentityService IdentityService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject IConfiguration Configuration
|
@inject FeatureSettings FeatureSettings
|
||||||
|
|
||||||
<div class="login-page-container">
|
<div class="login-page-container">
|
||||||
<div class="mesh-bg"></div>
|
<div class="mesh-bg"></div>
|
||||||
@@ -126,8 +126,8 @@
|
|||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
_allowRegistration = Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
|
_allowRegistration = FeatureSettings.AllowRegistration;
|
||||||
_allowPasswordReset = Configuration.GetValue<bool?>("Features:AllowPasswordReset") ?? true;
|
_allowPasswordReset = FeatureSettings.AllowPasswordReset;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(ErrorCode))
|
if (!string.IsNullOrEmpty(ErrorCode))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
@inject IIdentityService IdentityService
|
@inject IIdentityService IdentityService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject IConfiguration Configuration
|
@inject FeatureSettings FeatureSettings
|
||||||
|
|
||||||
<div class="login-page-container">
|
<div class="login-page-container">
|
||||||
<div class="mesh-bg"></div>
|
<div class="mesh-bg"></div>
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
var allowRegistration = Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
|
var allowRegistration = FeatureSettings.AllowRegistration;
|
||||||
if (!allowRegistration)
|
if (!allowRegistration)
|
||||||
{
|
{
|
||||||
NavigationManager.NavigateTo("/account/login?error=RegistrationDisabled", replace: true);
|
NavigationManager.NavigateTo("/account/login?error=RegistrationDisabled", replace: true);
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ else
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Logger.LogError(ex, "Simulated runtime JS Exception triggered and captured in Blazor UI");
|
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 { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strongly-typed feature settings for the client UI layer.
|
||||||
|
/// Used to decouple the UI from raw IConfiguration to prevent exposure of sensitive settings.
|
||||||
|
/// </summary>
|
||||||
|
public class FeatureSettings
|
||||||
|
{
|
||||||
|
public bool AllowRegistration { get; set; } = true;
|
||||||
|
public bool AllowPasswordReset { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ namespace NexusReader.UI.Shared.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Thread-safe implementation of IReaderStateService.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ReaderStateService : IReaderStateService
|
public sealed class ReaderStateService : IReaderStateService
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
|||||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||||
builder.Services.AddScoped<INativeStorageService, WebStorageService>();
|
builder.Services.AddScoped<INativeStorageService, WebStorageService>();
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
|
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
||||||
|
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||||
|
builder.Services.AddSingleton(featureSettings);
|
||||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
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>();
|
||||||
|
|||||||
@@ -37,7 +37,29 @@ public class WasmEpubReader : IEpubReader
|
|||||||
return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex));
|
return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Metadata extraction moved to WasmEpubMetadataExtractor
|
public async Task<Result<byte[]>> 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
|
public class WasmEpubMetadataExtractor : IEpubMetadataExtractor
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ builder.Services.AddHttpContextAccessor();
|
|||||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||||
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
|
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
|
||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
|
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
||||||
|
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||||
|
builder.Services.AddSingleton(featureSettings);
|
||||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
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>();
|
||||||
@@ -297,6 +300,50 @@ app.MapGet("/api/epub/{ebookId:guid}/{index:int}", async (Guid ebookId, int inde
|
|||||||
return Results.BadRequest(errorMsg);
|
return Results.BadRequest(errorMsg);
|
||||||
}).RequireAuthorization();
|
}).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")
|
var knowledgeApi = app.MapGroup("/api/knowledge")
|
||||||
.RequireAuthorization("HasAvailableTokens")
|
.RequireAuthorization("HasAvailableTokens")
|
||||||
.DisableAntiforgery();
|
.DisableAntiforgery();
|
||||||
|
|||||||
@@ -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<AppDbContext> _contextOptions;
|
||||||
|
private readonly Mock<IDbContextFactory<AppDbContext>> _dbContextFactoryMock;
|
||||||
|
private readonly Mock<ILogger<EpubReaderService>> _loggerMock;
|
||||||
|
|
||||||
|
public EpubReaderServiceTests()
|
||||||
|
{
|
||||||
|
_connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
_connection.Open();
|
||||||
|
|
||||||
|
_contextOptions = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(_connection)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
// Seed initial database schema
|
||||||
|
using var context = new AppDbContext(_contextOptions);
|
||||||
|
context.Database.EnsureCreated();
|
||||||
|
|
||||||
|
_dbContextFactoryMock = new Mock<IDbContextFactory<AppDbContext>>();
|
||||||
|
_dbContextFactoryMock.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(() => new AppDbContext(_contextOptions));
|
||||||
|
_dbContextFactoryMock.Setup(f => f.CreateDbContext())
|
||||||
|
.Returns(() => new AppDbContext(_contextOptions));
|
||||||
|
|
||||||
|
_loggerMock = new Mock<ILogger<EpubReaderService>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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("<img"))
|
||||||
|
{
|
||||||
|
hasImages = true;
|
||||||
|
textBlock.Content.Should().Contain($"/api/epub/{ebookId}/resource?path=");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output result for developer sanity check
|
||||||
|
Console.WriteLine($"Epub parsed successfully. Image tags found: {hasImages}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEpubResourceAsync_ExtractsValidEpubResource()
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// First find a valid image or resource path in the book by getting the content or accessing a known path.
|
||||||
|
// Lives of the Most Excellent Painters contains OEBPS/images/cover.jpg or similar.
|
||||||
|
// Let's call GetEpubResourceAsync on a common path (e.g. OEBPS/images/cover.jpg)
|
||||||
|
// Since we don't know the exact path in advance, let's try a few standard locations or look up a file.
|
||||||
|
var targetResource = "OEBPS/images/cover.jpg";
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await service.GetEpubResourceAsync(ebookId, targetResource, userId);
|
||||||
|
|
||||||
|
// Assert - if it is found, it must return success and bytes.
|
||||||
|
// If the path is different, we can try another or assert the failure is at least not a crash.
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
result.Value.Should().NotBeNull();
|
||||||
|
result.Value.Length.Should().BeGreaterThan(0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Try fallback cover or other typical EPUB resources
|
||||||
|
var fallbackResult = await service.GetEpubResourceAsync(ebookId, "images/cover.jpg", userId);
|
||||||
|
if (fallbackResult.IsSuccess)
|
||||||
|
{
|
||||||
|
fallbackResult.Value.Should().NotBeNull();
|
||||||
|
fallbackResult.Value.Length.Should().BeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SanitizeParagraph_StripsUnsafeAttributesFromImgTags()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var method = typeof(EpubReaderService).GetMethod("SanitizeParagraph", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||||
|
method.Should().NotBeNull();
|
||||||
|
|
||||||
|
var input = "<img src=\"images/cover.jpg\" alt=\"Cover Image\" onerror=\"alert(1)\" onload=\"evil()\" style=\"color:red\" class=\"img-responsive\" />";
|
||||||
|
|
||||||
|
// 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 = "<img src=\"javascript:alert(1)\" />";
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user