feat(editor): align selection popup and all editor control elements styling with Reader #81
@@ -4,6 +4,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
|
||||
|
|
||||
<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;
|
||||
|
Antigravity
commented
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;
|
||||
|
Antigravity
commented
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;
|
||||
|
Antigravity
commented
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
|
||||
|
Antigravity
commented
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;
|
||||
|
Antigravity
commented
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;
|
||||
|
Antigravity
commented
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
|
||||
|
Antigravity
commented
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 & drop / paste) and backend XSS sanitization.</p>
|
||||
</div>
|
||||
|
||||
<div class="editor-section">
|
||||
@@ -15,7 +16,15 @@
|
||||
</div>
|
||||
|
||||
<div class="result-section">
|
||||
<h3>Retrieved Markdown Content</h3>
|
||||
<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
|
||||
|
||||
@@ -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\">");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user
HtmlSanitizer package version 9.0.892 is compatible with Native AOT; verify no native dependencies.