3 Commits

17 changed files with 822 additions and 0 deletions
+1
View File
@@ -4,6 +4,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="FluentResults" Version="4.0.0" /> <PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
<PackageVersion Include="Mapster" Version="10.0.7" /> <PackageVersion Include="Mapster" Version="10.0.7" />
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" /> <PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="MediatR" Version="12.1.1" /> <PackageVersion Include="MediatR" Version="12.1.1" />
@@ -0,0 +1,12 @@
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// Service for sanitizing raw input text (e.g. Markdown/HTML) to protect against XSS injection.
/// </summary>
public interface ISanitizerService
{
/// <summary>
/// Sanitizes the input string and returns a clean, safe version.
/// </summary>
string Sanitize(string input);
}
@@ -0,0 +1,17 @@
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// General file storage service interface for handling media uploads.
/// </summary>
public interface IStorageService
{
/// <summary>
/// Uploads a file stream and returns its public URL/path.
/// </summary>
Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType);
/// <summary>
/// Uploads file bytes and returns its public URL/path.
/// </summary>
Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType);
}
@@ -18,6 +18,9 @@ namespace NexusReader.Application.Common;
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))] [JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.User.UpdateThemeRequest))] [JsonSerializable(typeof(NexusReader.Application.DTOs.User.UpdateThemeRequest))]
[JsonSerializable(typeof(NexusReader.Domain.Enums.ThemeMode))] [JsonSerializable(typeof(NexusReader.Domain.Enums.ThemeMode))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterRequest))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterResponse))]
[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.UploadResultDto))]
public partial class AppJsonContext : JsonSerializerContext public partial class AppJsonContext : JsonSerializerContext
{ {
} }
@@ -0,0 +1,16 @@
namespace NexusReader.Application.DTOs.Media;
/// <summary>
/// Request DTO for chapter validation/sanitization.
/// </summary>
public record ValidateChapterRequest(string Content);
/// <summary>
/// Response DTO containing sanitized chapter content.
/// </summary>
public record ValidateChapterResponse(string SanitizedContent);
/// <summary>
/// Response DTO containing the uploaded media file URL.
/// </summary>
public record UploadResultDto(string Url);
@@ -124,6 +124,8 @@ public static class DependencyInjection
// Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution // Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution
// that is environment-specific and incompatible with Singleton lifetime in MAUI. // that is environment-specific and incompatible with Singleton lifetime in MAUI.
services.AddScoped<IBookStorageService, BookStorageService>(); services.AddScoped<IBookStorageService, BookStorageService>();
services.AddScoped<IStorageService, LocalStorageService>();
services.AddSingleton<ISanitizerService, HtmlSanitizerService>();
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime) // Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
services.AddScoped<IEbookRepository, EbookRepository>(); services.AddScoped<IEbookRepository, EbookRepository>();
@@ -28,6 +28,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Polly" /> <PackageReference Include="Polly" />
<PackageReference Include="Polly.Extensions.Http" /> <PackageReference Include="Polly.Extensions.Http" />
<PackageReference Include="HtmlSanitizer" />
<PackageReference Include="Qdrant.Client" /> <PackageReference Include="Qdrant.Client" />
<PackageReference Include="Stripe.net" /> <PackageReference Include="Stripe.net" />
<PackageReference Include="VersOne.Epub" /> <PackageReference Include="VersOne.Epub" />
@@ -0,0 +1,30 @@
using Ganss.Xss;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Infrastructure.Services;
/// <summary>
/// Infrastructure implementation of ISanitizerService using the Ganss.Xss HtmlSanitizer library.
/// </summary>
public class HtmlSanitizerService : ISanitizerService
{
private readonly HtmlSanitizer _sanitizer;
public HtmlSanitizerService()
{
_sanitizer = new HtmlSanitizer();
// Use default configuration which is extremely secure and strips
// all JavaScript (script tags, onerror, onload, iframe, etc.)
}
public string Sanitize(string input)
{
if (string.IsNullOrEmpty(input))
{
return input;
}
return _sanitizer.Sanitize(input);
}
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Hosting;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Infrastructure.Services;
/// <summary>
/// Infrastructure implementation of general storage utilizing local filesystem.
/// Files are saved in wwwroot/uploads/media.
/// </summary>
public class LocalStorageService : IStorageService
{
private readonly IWebHostEnvironment _environment;
public LocalStorageService(IWebHostEnvironment environment)
{
_environment = environment;
}
public async Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType)
{
using var stream = new MemoryStream(fileBytes);
return await UploadFileAsync(stream, fileName, contentType);
}
public async Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType)
{
var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads", "media");
if (!Directory.Exists(mediaFolder))
{
Directory.CreateDirectory(mediaFolder);
}
// Clean file name to prevent path traversal issues
var safeFileName = Path.GetFileName(fileName);
var uniqueFileName = $"{Guid.NewGuid()}_{safeFileName}";
var filePath = Path.Combine(mediaFolder, uniqueFileName);
using (var outputStream = new FileStream(filePath, FileMode.Create))
{
await fileStream.CopyToAsync(outputStream);
}
// Return the public web-relative URL
return $"/uploads/media/{uniqueFileName}";
}
}
@@ -0,0 +1,129 @@
@using Microsoft.JSInterop
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject HttpClient Http
<div class="markdown-editor-container" style="height: @Height; width: @Width;">
<div id="@EditorId" class="milkdown-editor-wrapper"></div>
<div class="editor-actions">
<button type="button" @onclick="FetchContentAsync" class="nexus-btn">
Fetch Markdown Content
</button>
</div>
</div>
@code {
private readonly string EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
private IJSObjectReference? _module;
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
[Parameter]
public string InitialMarkdown { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> OnSave { get; set; }
[Parameter]
public string Height { get; set; } = "500px";
[Parameter]
public string Width { get; set; } = "100%";
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_dotNetHelper = DotNetObjectReference.Create(this);
try
{
// Import the isolated JavaScript module
_module = await JS.InvokeAsync<IJSObjectReference>(
"import",
"./_content/NexusReader.UI.Shared/js/milkdownWrapper.js"
);
// Call the initialization function in the wrapper
await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown);
}
catch (Exception ex)
{
// Log the exception gracefully and do not crash the component
Console.WriteLine($"[MarkdownEditor] Error initializing Milkdown editor: {ex.Message}");
}
}
}
private async Task FetchContentAsync()
{
if (_module is not null)
{
try
{
// Retrieve the updated markdown from JS
var markdown = await _module.InvokeAsync<string>("getMarkdownContent", EditorId);
if (OnSave.HasDelegate)
{
await OnSave.InvokeAsync(markdown);
}
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Error fetching markdown content: {ex.Message}");
}
}
}
[JSInvokable]
public async Task<string> UploadImageFromJs(string filename, string contentType, byte[] fileBytes)
{
try
{
using var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
content.Add(fileContent, "file", filename);
var response = await Http.PostAsync("/api/media/upload", content);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<NexusReader.Application.DTOs.Media.UploadResultDto>(
NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto);
return result?.Url ?? string.Empty;
}
else
{
var errorMsg = await response.Content.ReadAsStringAsync();
Console.WriteLine($"[MarkdownEditor] Image upload failed: {response.StatusCode} - {errorMsg}");
return string.Empty;
}
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Exception during image upload: {ex.Message}");
return string.Empty;
}
}
public async ValueTask DisposeAsync()
{
try
{
if (_module is not null)
{
// Clean up the JS editor instance to prevent memory leaks
await _module.InvokeVoidAsync("destroyEditor", EditorId);
await _module.DisposeAsync();
}
}
catch (Exception ex)
{
// Fail silently during page navigation/webview closures to avoid noisy logs
Console.WriteLine($"[MarkdownEditor] Error during JS cleanup: {ex.Message}");
}
finally
{
_dotNetHelper?.Dispose();
}
}
}
@@ -0,0 +1,81 @@
.markdown-editor-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.milkdown-editor-wrapper {
flex: 1;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-surface);
overflow: auto;
padding: 1.5rem;
position: relative;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.milkdown-editor-wrapper:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-glow);
}
.editor-actions {
display: flex;
justify-content: flex-end;
}
/* 3. Bypassing Blazor CSS Isolation for Dynamic JS DOMs using ::deep */
::deep .milkdown-editor-wrapper .crepe {
max-width: 100% !important;
}
::deep .milkdown-editor-wrapper .milkdown {
background-color: var(--bg-surface) !important;
color: var(--text-main) !important;
font-family: var(--nexus-font-sans) !important;
border: none !important;
box-shadow: none !important;
/* Map Crepe's internal variables to our design tokens */
--crepe-color-background: var(--bg-surface);
--crepe-color-on-background: var(--text-main);
--crepe-color-surface: rgba(255, 255, 255, 0.03);
--crepe-color-surface-low: rgba(255, 255, 255, 0.01);
--crepe-color-primary: var(--accent);
--crepe-color-outline: var(--border);
}
::deep .milkdown-editor-wrapper .milkdown .editor {
color: var(--text-main) !important;
background: transparent !important;
outline: none !important;
padding: 0.5rem 0 !important;
min-height: 200px;
}
/* Style the buttons using variables from app.css */
.nexus-btn {
font-family: var(--nexus-font-sans);
font-weight: 600;
border-radius: var(--radius-md);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
text-decoration: none;
background: var(--nexus-neon);
color: #000000;
padding: 8px 16px;
font-size: 0.9rem;
min-height: 36px;
}
.nexus-btn:hover {
transform: translateY(-2px);
filter: brightness(1.1);
box-shadow: 0 4px 15px var(--nexus-primary-glow);
}
@@ -95,6 +95,12 @@
</div> </div>
<span class="nav-text">Koncentry</span> <span class="nav-text">Koncentry</span>
</NavLink> </NavLink>
<NavLink class="nav-item" href="/dev/creator-test" @onclick="CloseMobileMenu" title="Kreator (Dev)" aria-label="Kreator (Dev)">
<div class="nav-icon">
<NexusIcon Name="edit" Size="20" />
</div>
<span class="nav-text">Kreator (Dev)</span>
</NavLink>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -0,0 +1,128 @@
@page "/dev/creator-test"
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
@inject HttpClient Http
<PageTitle>Markdown Creator Test</PageTitle>
<div class="creator-test-container glass-panel">
<div class="test-header">
<h1>Milkdown WYSIWYG Integration (Stage 2)</h1>
<p class="subtitle">Verifying secure image upload (drag &amp; drop / paste) and backend XSS sanitization.</p>
</div>
<div class="editor-section">
<MarkdownEditor InitialMarkdown="@_initialMarkdown" OnSave="HandleSave" Height="400px" />
</div>
<div class="result-section">
<div class="result-header">
<h3>Retrieved Markdown Content</h3>
@if (!string.IsNullOrEmpty(_savedMarkdown))
{
<button type="button" @onclick="SanitizeContentAsync" class="nexus-btn secondary-btn">
Run XSS Guard Sanitization
</button>
}
</div>
<p class="description">This block shows the content received from the editor when you click "Fetch Markdown Content".</p>
<div class="pre-wrapper">
@if (string.IsNullOrEmpty(_savedMarkdown))
{
<span class="placeholder">No content fetched yet. Click "Fetch Markdown Content" above to retrieve data.</span>
}
else
{
<pre><code>@_savedMarkdown</code></pre>
}
</div>
@if (!string.IsNullOrEmpty(_sanitizedMarkdown))
{
<div class="sanitized-container">
<h3>Sanitized Content (XSS Shielded)</h3>
<p class="description">This block shows the content after passing through the backend `HtmlSanitizer` API.</p>
<div class="pre-wrapper sanitized-wrapper">
<pre><code>@_sanitizedMarkdown</code></pre>
</div>
</div>
}
</div>
</div>
@code {
private readonly string _initialMarkdown = @"# Milkdown WYSIWYG Test Page (Stage 2)
This is a demonstration of the **Milkdown** editor embedded inside a Blazor WASM component.
## Secure Image Upload (Drag & Drop / Paste)
You can drag and drop or copy-paste an image (JPEG, PNG, WEBP) directly into this editor area. The file will be intercepted, sent as a byte stream to the backend `/api/media/upload` endpoint, validated for magic numbers and file size limits (max 5MB), saved locally, and rendered inside the editor.
## XSS Security Test
Here is some malicious HTML to test the backend XSS Guard:
<script>alert('xss')</script>
<img src=x onerror=alert(1)>
## GFM Features Support
The editor supports Github Flavored Markdown out-of-the-box:
1. **Task Lists**
- [x] Create reusable Blazor component
- [x] Configure ESM dynamic wrapper
- [x] Implement stage 2 features
2. **Tables**
| Feature | Stage 1 Status | Stage 2 Plan |
| :--- | :---: | :---: |
| WYSIWYG Mode | Active | Polish UI |
| C# Interop | Done | Auto-Sync |
| GFM Support | Verified | Custom Nodes |
Feel free to edit this text and click **Fetch Markdown Content** below!";
private string _savedMarkdown = string.Empty;
private string _sanitizedMarkdown = string.Empty;
private void HandleSave(string markdown)
{
_savedMarkdown = markdown;
_sanitizedMarkdown = string.Empty; // Reset sanitization result
StateHasChanged();
}
private async Task SanitizeContentAsync()
{
if (string.IsNullOrEmpty(_savedMarkdown)) return;
try
{
var request = new NexusReader.Application.DTOs.Media.ValidateChapterRequest(_savedMarkdown);
var response = await Http.PostAsJsonAsync(
"/api/chapters/validate",
request,
NexusReader.Application.Common.AppJsonContext.Default.ValidateChapterRequest
);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<NexusReader.Application.DTOs.Media.ValidateChapterResponse>(
NexusReader.Application.Common.AppJsonContext.Default.ValidateChapterResponse
);
_sanitizedMarkdown = result?.SanitizedContent ?? string.Empty;
}
else
{
_sanitizedMarkdown = $"Error: {response.StatusCode}";
}
}
catch (Exception ex)
{
_sanitizedMarkdown = $"Exception: {ex.Message}";
}
StateHasChanged();
}
}
@@ -0,0 +1,108 @@
.creator-test-container {
max-width: 1000px;
margin: 2rem auto;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.test-header h1 {
font-size: 1.75rem;
color: var(--text-main);
margin: 0 0 0.5rem 0;
}
.test-header .subtitle {
font-size: 0.95rem;
color: var(--text-muted);
margin: 0;
}
.editor-section {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
overflow: hidden;
}
.result-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.result-section h3 {
margin: 0;
font-size: 1.2rem;
color: var(--text-main);
}
.sanitized-container {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
border-top: 1px dashed var(--border);
padding-top: 1.5rem;
}
.sanitized-wrapper {
border-color: rgba(0, 255, 153, 0.25);
box-shadow: 0 0 10px rgba(0, 255, 153, 0.05);
}
.nexus-btn.secondary-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.nexus-btn.secondary-btn:hover {
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 15px rgba(255, 255, 255, 0.05);
}
.result-section .description {
font-size: 0.85rem;
color: var(--text-muted);
margin: 0 0 0.5rem 0;
}
.pre-wrapper {
background: #09090b;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 1.2rem;
max-height: 400px;
overflow-y: auto;
}
.pre-wrapper pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
font-size: 0.9rem;
color: #e4e4e7;
line-height: 1.5;
}
.placeholder {
color: var(--text-muted);
font-size: 0.9rem;
font-style: italic;
}
@@ -0,0 +1,101 @@
// Map to keep track of active Crepe editor instances by elementId (container ID)
const editors = new Map();
/**
* Asynchronously injects a stylesheet link tag into the document head
* and returns a Promise that resolves when the stylesheet is fully loaded.
*/
async function injectStylesheet(url) {
if (document.querySelector(`link[href="${url}"]`)) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.onload = () => resolve();
link.onerror = (err) => reject(new Error(`Failed to load stylesheet: ${url}. ${err}`));
document.head.appendChild(link);
});
}
/**
* Initializes a Milkdown Crepe editor on the specified element.
*/
export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
const container = document.getElementById(elementId);
if (!container) {
console.error(`[Milkdown] Container with ID "${elementId}" not found.`);
return;
}
try {
// Condition 2: Prevent FOUC by loading stylesheets before instantiating the editor
await Promise.all([
injectStylesheet('https://esm.sh/@milkdown/crepe/theme/common/style.css'),
injectStylesheet('https://esm.sh/@milkdown/crepe/theme/frame.css')
]);
// Dynamically import the Crepe ESM module
const { Crepe } = await import('https://esm.sh/@milkdown/crepe');
// Initialize the Crepe editor instance with custom ImageBlock upload handler
const crepe = new Crepe({
root: container,
defaultValue: initialMarkdown || "",
featureConfigs: {
[Crepe.Feature.ImageBlock]: {
onUpload: async (file) => {
try {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const url = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, uint8Array);
return url;
} catch (err) {
console.error("[Milkdown] Failed to upload image from JS:", err);
throw err;
}
}
}
}
});
// Store the editor instance in the map
editors.set(elementId, crepe);
// Create the editor view asynchronously
await crepe.create();
console.log(`[Milkdown] Editor successfully initialized on element: ${elementId}`);
} catch (error) {
console.error(`[Milkdown] Failed to initialize editor on "${elementId}":`, error);
}
}
/**
* Retrieves the current Markdown content from a specific editor instance.
*/
export function getMarkdownContent(elementId) {
const crepe = editors.get(elementId);
if (!crepe) {
console.warn(`[Milkdown] No editor instance found for element: ${elementId}`);
return "";
}
return crepe.getMarkdown();
}
/**
* Safely disposes of the editor instance to prevent memory leaks in WASM.
*/
export async function destroyEditor(elementId) {
const crepe = editors.get(elementId);
if (crepe) {
try {
await crepe.destroy();
console.log(`[Milkdown] Editor instance successfully destroyed: ${elementId}`);
} catch (error) {
console.error(`[Milkdown] Error destroying editor for element "${elementId}":`, error);
}
editors.delete(elementId);
}
}
+86
View File
@@ -769,6 +769,64 @@ app.MapPost("/identity/theme", async (
return Results.Ok(); return Results.Ok();
}).RequireAuthorization(); }).RequireAuthorization();
app.MapPost("/api/media/upload", async (
HttpRequest request,
NexusReader.Application.Abstractions.Services.IStorageService storageService,
ILogger<Program> logger) =>
{
if (!request.HasFormContentType)
{
return Results.BadRequest("Request must be a multipart form.");
}
var form = await request.ReadFormAsync();
var file = form.Files.GetFile("file");
if (file == null || file.Length == 0)
{
return Results.BadRequest("No file uploaded.");
}
// Size limit check (max 5MB)
const long maxFileSize = 5 * 1024 * 1024;
if (file.Length > maxFileSize)
{
return Results.BadRequest("File size exceeds the 5MB limit.");
}
// Read file bytes for signature check
byte[] fileBytes;
using (var memoryStream = new MemoryStream())
{
await file.CopyToAsync(memoryStream);
fileBytes = memoryStream.ToArray();
}
// Validate signature
if (!ValidateImageSignature(fileBytes, file.ContentType))
{
logger.LogWarning("File signature validation failed for file {FileName} with content type {ContentType}.", file.FileName, file.ContentType);
return Results.BadRequest("Invalid image signature. Legitimate JPEG, PNG, or WEBP images only.");
}
// Save using IStorageService
var fileUrl = await storageService.UploadFileAsync(fileBytes, file.FileName, file.ContentType);
return Results.Ok(new NexusReader.Application.DTOs.Media.UploadResultDto(fileUrl));
}).DisableAntiforgery();
app.MapPost("/api/chapters/validate", (
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.Media.ValidateChapterRequest request,
NexusReader.Application.Abstractions.Services.ISanitizerService sanitizerService) =>
{
if (request == null || string.IsNullOrEmpty(request.Content))
{
return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(string.Empty));
}
var sanitized = sanitizerService.Sanitize(request.Content);
return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(sanitized));
}).DisableAntiforgery();
app.MapRazorComponents<App>() app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode() .AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode() .AddInteractiveWebAssemblyRenderMode()
@@ -820,6 +878,34 @@ async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider se
} }
} }
static bool ValidateImageSignature(byte[] bytes, string contentType)
{
if (bytes.Length < 4) return false;
// Check PNG signature: 89 50 4E 47
if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
{
return contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase);
}
// Check JPEG signature: FF D8 FF
if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
{
return contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) ||
contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase);
}
// Check WEBP signature: RIFF ... WEBP
if (bytes.Length >= 12 &&
bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && // RIFF
bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) // WEBP
{
return contentType.Equals("image/webp", StringComparison.OrdinalIgnoreCase);
}
return false;
}
public record KnowledgeRequest(string Text, Guid? EbookId = null); public record KnowledgeRequest(string Text, Guid? EbookId = null);
public record GroundednessRequest(string Answer, string Context); public record GroundednessRequest(string Answer, string Context);
public record SemanticSearchRequest(string QueryText, int Limit = 5); public record SemanticSearchRequest(string QueryText, int Limit = 5);
@@ -0,0 +1,54 @@
using FluentAssertions;
using NexusReader.Infrastructure.Services;
using Xunit;
namespace NexusReader.Application.Tests.Services;
public class HtmlSanitizerServiceTests
{
[Fact]
public void Sanitize_WithSafeInput_ReturnsSameInput()
{
// Arrange
var service = new HtmlSanitizerService();
var input = "<p>This is a safe <strong>paragraph</strong>.</p>";
// Act
var result = service.Sanitize(input);
// Assert
result.Should().Be(input);
}
[Fact]
public void Sanitize_WithScriptTag_StripsScriptTag()
{
// Arrange
var service = new HtmlSanitizerService();
var input = "<p>Hello</p><script>alert('xss')</script>";
// Act
var result = service.Sanitize(input);
// Assert
result.Should().NotContain("<script>");
result.Should().NotContain("alert");
result.Should().Be("<p>Hello</p>");
}
[Fact]
public void Sanitize_WithOnEventHandlerAttribute_StripsOnError()
{
// Arrange
var service = new HtmlSanitizerService();
var input = "<img src=\"x\" onerror=\"alert(1)\" />";
// Act
var result = service.Sanitize(input);
// Assert
result.Should().NotContain("onerror");
result.Should().NotContain("alert");
result.Should().Contain("<img src=\"x\">");
}
}