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