refactor: resolve review comments in PR #81 (issuecomment-542)

This commit is contained in:
2026-06-11 20:06:54 +02:00
parent 039973b974
commit 81aba4b443
12 changed files with 192 additions and 84 deletions
@@ -2,6 +2,7 @@ namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// Service for sanitizing raw input text (e.g. Markdown/HTML) to protect against XSS injection.
/// Intended to have a Singleton lifetime.
/// </summary>
public interface ISanitizerService
{
@@ -2,6 +2,7 @@ namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// General file storage service interface for handling media uploads.
/// Intended to have a Scoped lifetime.
/// </summary>
public interface IStorageService
{
@@ -1,5 +1,7 @@
namespace NexusReader.Application.DTOs.Media;
// Note: These DTOs are registered in AppJsonContext.cs for JSON source generation.
/// <summary>
/// Request DTO for chapter validation/sanitization.
/// </summary>
@@ -0,0 +1,35 @@
using System.Collections.Generic;
namespace NexusReader.Infrastructure.Configuration;
/// <summary>
/// Settings for configuring allowed tags, attributes, CSS properties, and schemes in HtmlSanitizerService.
/// </summary>
public class HtmlSanitizerSettings
{
public const string SectionName = "HtmlSanitizer";
/// <summary>
/// Gets or sets the list of HTML tags that are allowed.
/// If null or empty, the default allowed tags list is used.
/// </summary>
public List<string>? AllowedTags { get; set; }
/// <summary>
/// Gets or sets the list of HTML attributes that are allowed.
/// If null or empty, the default allowed attributes list is used.
/// </summary>
public List<string>? AllowedAttributes { get; set; }
/// <summary>
/// Gets or sets the list of CSS properties that are allowed.
/// If null or empty, the default allowed CSS properties list is used.
/// </summary>
public List<string>? AllowedCssProperties { get; set; }
/// <summary>
/// Gets or sets the list of URI schemes that are allowed (e.g. "http", "https").
/// If null or empty, the default allowed schemes list is used.
/// </summary>
public List<string>? AllowedSchemes { get; set; }
}
@@ -78,6 +78,7 @@ public static class DependencyInjection
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
services.Configure<RagMonetizationOptions>(configuration.GetSection(RagMonetizationOptions.SectionName));
services.Configure<HtmlSanitizerSettings>(configuration.GetSection(HtmlSanitizerSettings.SectionName));
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
@@ -1,5 +1,7 @@
using Ganss.Xss;
using Microsoft.Extensions.Options;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Configuration;
namespace NexusReader.Infrastructure.Services;
@@ -10,12 +12,50 @@ public class HtmlSanitizerService : ISanitizerService
{
private readonly HtmlSanitizer _sanitizer;
public HtmlSanitizerService()
public HtmlSanitizerService(IOptions<HtmlSanitizerSettings>? options = null)
{
_sanitizer = new HtmlSanitizer();
// Use default configuration which is extremely secure and strips
// all JavaScript (script tags, onerror, onload, iframe, etc.)
if (options?.Value != null)
{
var settings = options.Value;
if (settings.AllowedTags != null && settings.AllowedTags.Count > 0)
{
_sanitizer.AllowedTags.Clear();
foreach (var tag in settings.AllowedTags)
{
_sanitizer.AllowedTags.Add(tag);
}
}
if (settings.AllowedAttributes != null && settings.AllowedAttributes.Count > 0)
{
_sanitizer.AllowedAttributes.Clear();
foreach (var attr in settings.AllowedAttributes)
{
_sanitizer.AllowedAttributes.Add(attr);
}
}
if (settings.AllowedCssProperties != null && settings.AllowedCssProperties.Count > 0)
{
_sanitizer.AllowedCssProperties.Clear();
foreach (var prop in settings.AllowedCssProperties)
{
_sanitizer.AllowedCssProperties.Add(prop);
}
}
if (settings.AllowedSchemes != null && settings.AllowedSchemes.Count > 0)
{
_sanitizer.AllowedSchemes.Clear();
foreach (var scheme in settings.AllowedSchemes)
{
_sanitizer.AllowedSchemes.Add(scheme);
}
}
}
}
public string Sanitize(string input)
@@ -25,18 +25,29 @@ public class LocalStorageService : IStorageService
public async Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType)
{
var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads", "media");
var resolvedMediaFolder = Path.GetFullPath(mediaFolder);
var folderWithSeparator = resolvedMediaFolder.EndsWith(Path.DirectorySeparatorChar)
? resolvedMediaFolder
: resolvedMediaFolder + Path.DirectorySeparatorChar;
if (!Directory.Exists(mediaFolder))
if (!Directory.Exists(resolvedMediaFolder))
{
Directory.CreateDirectory(mediaFolder);
Directory.CreateDirectory(resolvedMediaFolder);
}
// Clean file name to prevent path traversal issues
var safeFileName = Path.GetFileName(fileName);
var uniqueFileName = $"{Guid.NewGuid()}_{safeFileName}";
var filePath = Path.Combine(mediaFolder, uniqueFileName);
var filePath = Path.Combine(resolvedMediaFolder, uniqueFileName);
// Guard against path traversal
var fullPath = Path.GetFullPath(filePath);
if (!fullPath.StartsWith(folderWithSeparator, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Path traversal detected.");
}
using (var outputStream = new FileStream(filePath, FileMode.Create))
using (var outputStream = new FileStream(fullPath, FileMode.Create))
{
await fileStream.CopyToAsync(outputStream);
}
@@ -17,6 +17,7 @@
@code {
private readonly string EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
private readonly CancellationTokenSource _cts = new();
private IJSObjectReference? _module;
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
@@ -90,11 +91,11 @@
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
content.Add(fileContent, "file", filename);
var response = await Http.PostAsync("/api/media/upload", content);
var response = await Http.PostAsync("/api/media/upload", content, _cts.Token);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<NexusReader.Application.DTOs.Media.UploadResultDto>(
NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto);
NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto, _cts.Token);
return result?.Url ?? string.Empty;
}
else
@@ -113,6 +114,16 @@
public async ValueTask DisposeAsync()
{
try
{
_cts.Cancel();
_cts.Dispose();
}
catch
{
// Fail silently if cancellation token disposal fails
}
try
{
if (_module is not null)
@@ -79,3 +79,8 @@
filter: brightness(1.1);
box-shadow: 0 4px 15px var(--nexus-primary-glow);
}
.nexus-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@@ -1,6 +1,6 @@
@page "/creator"
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
@attribute [Authorize]
<PageTitle>Kreator Treści (Zen Mode)</PageTitle>
@@ -60,7 +60,7 @@
/* 3. Deep Cascading Overrides to target dynamic editor components */
::deep .markdown-editor-container {
.creator-fullscreen-wrapper ::deep .markdown-editor-container {
height: 100% !important;
display: flex !important;
flex-direction: column !important;
@@ -68,7 +68,7 @@
overflow: hidden !important;
}
::deep .milkdown-editor-wrapper {
.creator-fullscreen-wrapper ::deep .milkdown-editor-wrapper {
display: flex !important;
flex-direction: column !important;
flex-grow: 1 !important;
@@ -76,8 +76,8 @@
}
/* Force crepe and milkdown inner wrappers to stretch */
::deep .crepe,
::deep .milkdown {
.creator-fullscreen-wrapper ::deep .crepe,
.creator-fullscreen-wrapper ::deep .milkdown {
width: 100% !important;
max-width: 100% !important;
display: flex !important;
@@ -89,9 +89,9 @@
}
/* Pin the toolbar at the top */
::deep .crepe .toolbar,
::deep .milkdown-menu,
::deep .crepe-menu-wrapper {
.creator-fullscreen-wrapper ::deep .crepe .toolbar,
.creator-fullscreen-wrapper ::deep .milkdown-menu,
.creator-fullscreen-wrapper ::deep .crepe-menu-wrapper {
flex-shrink: 0 !important;
background-color: var(--bg-base) !important;
border: 1px solid var(--border) !important;
@@ -100,20 +100,20 @@
margin-bottom: 1rem !important;
}
::deep .crepe .toolbar button:hover,
::deep .milkdown-menu button:hover,
::deep .crepe-menu-wrapper button:hover,
::deep .crepe .toolbar .button:hover,
::deep .milkdown-menu .button:hover {
.creator-fullscreen-wrapper ::deep .crepe .toolbar button:hover,
.creator-fullscreen-wrapper ::deep .milkdown-menu button:hover,
.creator-fullscreen-wrapper ::deep .crepe-menu-wrapper button:hover,
.creator-fullscreen-wrapper ::deep .crepe .toolbar .button:hover,
.creator-fullscreen-wrapper ::deep .milkdown-menu .button:hover {
color: var(--accent) !important;
background-color: rgba(16, 185, 129, 0.1) !important;
border-radius: var(--radius-sm, 4px) !important;
}
/* Relocate scrolling directly to ProseMirror editor layer and fix text clipping */
::deep .ProseMirror,
::deep .crepe .editor,
::deep .milkdown .editor {
.creator-fullscreen-wrapper ::deep .ProseMirror,
.creator-fullscreen-wrapper ::deep .crepe .editor,
.creator-fullscreen-wrapper ::deep .milkdown .editor {
position: relative !important;
top: 0 !important;
transform: none !important;
@@ -131,36 +131,36 @@
}
/* Custom narrow scrollbar mapped to var(--border) */
::deep .ProseMirror::-webkit-scrollbar,
::deep .crepe .editor::-webkit-scrollbar,
::deep .milkdown .editor::-webkit-scrollbar {
.creator-fullscreen-wrapper ::deep .ProseMirror::-webkit-scrollbar,
.creator-fullscreen-wrapper ::deep .crepe .editor::-webkit-scrollbar,
.creator-fullscreen-wrapper ::deep .milkdown .editor::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::deep .ProseMirror::-webkit-scrollbar-track,
::deep .crepe .editor::-webkit-scrollbar-track,
::deep .milkdown .editor::-webkit-scrollbar-track {
.creator-fullscreen-wrapper ::deep .ProseMirror::-webkit-scrollbar-track,
.creator-fullscreen-wrapper ::deep .crepe .editor::-webkit-scrollbar-track,
.creator-fullscreen-wrapper ::deep .milkdown .editor::-webkit-scrollbar-track {
background: transparent;
}
::deep .ProseMirror::-webkit-scrollbar-thumb,
::deep .crepe .editor::-webkit-scrollbar-thumb,
::deep .milkdown .editor::-webkit-scrollbar-thumb {
.creator-fullscreen-wrapper ::deep .ProseMirror::-webkit-scrollbar-thumb,
.creator-fullscreen-wrapper ::deep .crepe .editor::-webkit-scrollbar-thumb,
.creator-fullscreen-wrapper ::deep .milkdown .editor::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::deep .ProseMirror::-webkit-scrollbar-thumb:hover,
::deep .crepe .editor::-webkit-scrollbar-thumb:hover,
::deep .milkdown .editor::-webkit-scrollbar-thumb:hover {
.creator-fullscreen-wrapper ::deep .ProseMirror::-webkit-scrollbar-thumb:hover,
.creator-fullscreen-wrapper ::deep .crepe .editor::-webkit-scrollbar-thumb:hover,
.creator-fullscreen-wrapper ::deep .milkdown .editor::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Editorial Typography */
::deep .milkdown .editor h1,
::deep .crepe h1,
::deep .ProseMirror h1 {
.creator-fullscreen-wrapper ::deep .milkdown .editor h1,
.creator-fullscreen-wrapper ::deep .crepe h1,
.creator-fullscreen-wrapper ::deep .ProseMirror h1 {
margin-top: 1.8rem !important;
margin-bottom: 1rem !important;
font-size: 2.25rem !important;
@@ -169,9 +169,9 @@
line-height: 1.25 !important;
}
::deep .milkdown .editor h2,
::deep .crepe h2,
::deep .ProseMirror h2 {
.creator-fullscreen-wrapper ::deep .milkdown .editor h2,
.creator-fullscreen-wrapper ::deep .crepe h2,
.creator-fullscreen-wrapper ::deep .ProseMirror h2 {
margin-top: 1.5rem !important;
margin-bottom: 0.8rem !important;
font-size: 1.6rem !important;
@@ -180,9 +180,9 @@
line-height: 1.3 !important;
}
::deep .milkdown .editor h3,
::deep .crepe h3,
::deep .ProseMirror h3 {
.creator-fullscreen-wrapper ::deep .milkdown .editor h3,
.creator-fullscreen-wrapper ::deep .crepe h3,
.creator-fullscreen-wrapper ::deep .ProseMirror h3 {
margin-top: 1.3rem !important;
margin-bottom: 0.7rem !important;
font-size: 1.3rem !important;
@@ -191,80 +191,80 @@
line-height: 1.35 !important;
}
::deep .milkdown .editor code,
::deep .crepe code,
::deep .ProseMirror code {
.creator-fullscreen-wrapper ::deep .milkdown .editor code,
.creator-fullscreen-wrapper ::deep .crepe code,
.creator-fullscreen-wrapper ::deep .ProseMirror code {
background-color: rgba(16, 185, 129, 0.1) !important;
color: var(--accent) !important;
padding: 0.2rem 0.4rem !important;
border-radius: var(--radius-sm, 4px) !important;
font-family: 'Azeret Mono', monospace !important;
font-family: var(--nexus-font-mono) !important;
font-size: 0.85em !important;
}
/* Premium GFM Table Layouts */
::deep .milkdown-premium-container table,
::deep .crepe table,
::deep .milkdown table,
::deep .ProseMirror table {
.creator-fullscreen-wrapper ::deep .milkdown-premium-container table,
.creator-fullscreen-wrapper ::deep .crepe table,
.creator-fullscreen-wrapper ::deep .milkdown table,
.creator-fullscreen-wrapper ::deep .ProseMirror table {
width: 100% !important;
max-width: 100% !important;
border-collapse: collapse !important;
margin: 1.5rem 0 !important;
}
::deep .milkdown-premium-container th,
::deep .crepe th,
::deep .milkdown th,
::deep .ProseMirror th,
::deep .milkdown-premium-container td,
::deep .crepe td,
::deep .milkdown td,
::deep .ProseMirror td {
.creator-fullscreen-wrapper ::deep .milkdown-premium-container th,
.creator-fullscreen-wrapper ::deep .crepe th,
.creator-fullscreen-wrapper ::deep .milkdown th,
.creator-fullscreen-wrapper ::deep .ProseMirror th,
.creator-fullscreen-wrapper ::deep .milkdown-premium-container td,
.creator-fullscreen-wrapper ::deep .crepe td,
.creator-fullscreen-wrapper ::deep .milkdown td,
.creator-fullscreen-wrapper ::deep .ProseMirror td {
padding: 14px 18px !important;
border: 1px solid var(--border) !important;
}
::deep .milkdown-premium-container th,
::deep .crepe th,
::deep .milkdown th,
::deep .ProseMirror th {
.creator-fullscreen-wrapper ::deep .milkdown-premium-container th,
.creator-fullscreen-wrapper ::deep .crepe th,
.creator-fullscreen-wrapper ::deep .milkdown th,
.creator-fullscreen-wrapper ::deep .ProseMirror th {
background-color: var(--bg-base) !important;
color: var(--text-main) !important;
font-weight: 700 !important;
text-align: left !important;
}
::deep .milkdown-premium-container td,
::deep .crepe td,
::deep .milkdown td,
::deep .ProseMirror td {
.creator-fullscreen-wrapper ::deep .milkdown-premium-container td,
.creator-fullscreen-wrapper ::deep .crepe td,
.creator-fullscreen-wrapper ::deep .milkdown td,
.creator-fullscreen-wrapper ::deep .ProseMirror td {
color: var(--text-main) !important;
}
/* Zebra row background tints (Dark Mode default) */
::deep .milkdown-premium-container tr:nth-child(even),
::deep .crepe tr:nth-child(even),
::deep .milkdown tr:nth-child(even),
::deep .ProseMirror tr:nth-child(even) {
.creator-fullscreen-wrapper ::deep .milkdown-premium-container tr:nth-child(even),
.creator-fullscreen-wrapper ::deep .crepe tr:nth-child(even),
.creator-fullscreen-wrapper ::deep .milkdown tr:nth-child(even),
.creator-fullscreen-wrapper ::deep .ProseMirror tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.01) !important;
}
/* Zebra row background tints (Light Mode override) */
.theme-light ::deep .milkdown-premium-container tr:nth-child(even),
.theme-light ::deep .crepe tr:nth-child(even),
.theme-light ::deep .milkdown tr:nth-child(even),
.theme-light ::deep .ProseMirror tr:nth-child(even) {
.theme-light .creator-fullscreen-wrapper ::deep .milkdown-premium-container tr:nth-child(even),
.theme-light .creator-fullscreen-wrapper ::deep .crepe tr:nth-child(even),
.theme-light .creator-fullscreen-wrapper ::deep .milkdown tr:nth-child(even),
.theme-light .creator-fullscreen-wrapper ::deep .ProseMirror tr:nth-child(even) {
background-color: rgba(0, 0, 0, 0.015) !important;
}
/* Lists and Task Lists */
::deep .crepe ul,
::deep .crepe ol,
::deep .milkdown ul,
::deep .milkdown ol,
::deep .ProseMirror ul,
::deep .ProseMirror ol {
.creator-fullscreen-wrapper ::deep .crepe ul,
.creator-fullscreen-wrapper ::deep .crepe ol,
.creator-fullscreen-wrapper ::deep .milkdown ul,
.creator-fullscreen-wrapper ::deep .milkdown ol,
.creator-fullscreen-wrapper ::deep .ProseMirror ul,
.creator-fullscreen-wrapper ::deep .ProseMirror ol {
line-height: 1.7 !important;
}
@@ -19,6 +19,7 @@
--nexus-paper: #F9F9F9;
--nexus-font-sans: 'Inter', sans-serif;
--nexus-font-serif: 'Merriweather', serif;
--nexus-font-mono: 'Azeret Mono', monospace;
/* Global Selection Style Override */
--nexus-selection: rgba(0, 255, 153, 0.25);