feat(editor): align selection popup and all editor control elements styling with Reader #81

Merged
mjasin merged 14 commits from feature/milkdown-integration into develop 2026-06-11 18:07:53 +00:00
15 changed files with 419 additions and 14 deletions
Showing only changes of commit 1d391f36ed - Show all commits
+1
View File
@@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
Review

HtmlSanitizer package version 9.0.892 is compatible with Native AOT; verify no native dependencies.

HtmlSanitizer package version 9.0.892 is compatible with Native AOT; verify no native dependencies.
<PackageVersion Include="Mapster" Version="10.0.7" />
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="MediatR" Version="12.1.1" />
@@ -0,0 +1,12 @@
namespace NexusReader.Application.Abstractions.Services;
Review

Add XML comment indicating intended Singleton lifetime for ISanitizerService.

Add XML comment indicating intended Singleton lifetime for ISanitizerService.
/// <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;
Review

Add XML comment indicating Scoped lifetime for IStorageService.

Add XML comment indicating Scoped lifetime for IStorageService.
/// <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(NexusReader.Application.DTOs.User.UpdateThemeRequest))]
[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
{
}
@@ -0,0 +1,16 @@
namespace NexusReader.Application.DTOs.Media;
Review

Consider adding [JsonSerializable] attributes to DTOs if they will be used with source‑gen.

Consider adding [JsonSerializable] attributes to DTOs if they will be used with source‑gen.
/// <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
Review

Verify DI registrations are placed after any services that depend on the new ones.

Verify DI registrations are placed after any services that depend on the new ones.
// that is environment-specific and incompatible with Singleton lifetime in MAUI.
services.AddScoped<IBookStorageService, BookStorageService>();
services.AddScoped<IStorageService, LocalStorageService>();
services.AddSingleton<ISanitizerService, HtmlSanitizerService>();
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
services.AddScoped<IEbookRepository, EbookRepository>();
@@ -28,6 +28,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Polly" />
<PackageReference Include="Polly.Extensions.Http" />
<PackageReference Include="HtmlSanitizer" />
<PackageReference Include="Qdrant.Client" />
<PackageReference Include="Stripe.net" />
<PackageReference Include="VersOne.Epub" />
@@ -0,0 +1,30 @@
using Ganss.Xss;
Review

Inject configuration (allowed tags, etc.) into HtmlSanitizerService for future flexibility.

Inject configuration (allowed tags, etc.) into HtmlSanitizerService for future flexibility.
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;
Review

Ensure uploads folder has correct permissions and guard against path traversal in LocalStorageService.

Ensure uploads folder has correct permissions and guard against path traversal in LocalStorageService.
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}";
}
}
@@ -1,6 +1,7 @@
@using Microsoft.JSInterop
Review

Component implements IAsyncDisposable correctly; consider adding cancellation token to async calls.

Component implements IAsyncDisposable correctly; consider adding cancellation token to async calls.
@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>
@@ -73,6 +74,37 @@
}
}
[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
@@ -1,13 +1,14 @@
@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 1)</h1>
<p class="subtitle">Verifying bi-directional Markdown flow and GFM rendering.</p>
<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">
@@ -15,7 +16,15 @@
</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))
@@ -27,14 +36,35 @@
<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
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:
@@ -42,7 +72,7 @@ The editor supports Github Flavored Markdown out-of-the-box:
1. **Task Lists**
- [x] Create reusable Blazor component
- [x] Configure ESM dynamic wrapper
- [ ] Implement stage 2 features
- [x] Implement stage 2 features
2. **Tables**
@@ -52,21 +82,47 @@ The editor supports Github Flavored Markdown out-of-the-box:
| C# Interop | Done | Auto-Sync |
| GFM Support | Verified | Custom Nodes |
3. **Code Formatting**
```csharp
public class MarkdownEditor : ComponentBase
{
// C# interop logic
}
```
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();
}
}
@@ -37,12 +37,45 @@
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);
@@ -39,10 +39,25 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
// Dynamically import the Crepe ESM module
const { Crepe } = await import('https://esm.sh/@milkdown/crepe');
// Initialize the Crepe editor instance
// 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
+86
View File
@@ -769,6 +769,64 @@ app.MapPost("/identity/theme", async (
return Results.Ok();
}).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>()
.AddInteractiveServerRenderMode()
.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 GroundednessRequest(string Answer, string Context);
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\">");
}
}