diff --git a/src/NexusReader.Application/Abstractions/Services/ISanitizerService.cs b/src/NexusReader.Application/Abstractions/Services/ISanitizerService.cs
index 3c5d892..fa0dc91 100644
--- a/src/NexusReader.Application/Abstractions/Services/ISanitizerService.cs
+++ b/src/NexusReader.Application/Abstractions/Services/ISanitizerService.cs
@@ -2,6 +2,7 @@ namespace NexusReader.Application.Abstractions.Services;
///
/// Service for sanitizing raw input text (e.g. Markdown/HTML) to protect against XSS injection.
+/// Intended to have a Singleton lifetime.
///
public interface ISanitizerService
{
diff --git a/src/NexusReader.Application/Abstractions/Services/IStorageService.cs b/src/NexusReader.Application/Abstractions/Services/IStorageService.cs
index 95261f9..a2d8d84 100644
--- a/src/NexusReader.Application/Abstractions/Services/IStorageService.cs
+++ b/src/NexusReader.Application/Abstractions/Services/IStorageService.cs
@@ -2,6 +2,7 @@ namespace NexusReader.Application.Abstractions.Services;
///
/// General file storage service interface for handling media uploads.
+/// Intended to have a Scoped lifetime.
///
public interface IStorageService
{
diff --git a/src/NexusReader.Application/DTOs/Media/MediaDtos.cs b/src/NexusReader.Application/DTOs/Media/MediaDtos.cs
index ab27c65..8553e3a 100644
--- a/src/NexusReader.Application/DTOs/Media/MediaDtos.cs
+++ b/src/NexusReader.Application/DTOs/Media/MediaDtos.cs
@@ -1,5 +1,7 @@
namespace NexusReader.Application.DTOs.Media;
+// Note: These DTOs are registered in AppJsonContext.cs for JSON source generation.
+
///
/// Request DTO for chapter validation/sanitization.
///
diff --git a/src/NexusReader.Infrastructure/Configuration/HtmlSanitizerSettings.cs b/src/NexusReader.Infrastructure/Configuration/HtmlSanitizerSettings.cs
new file mode 100644
index 0000000..897b6e7
--- /dev/null
+++ b/src/NexusReader.Infrastructure/Configuration/HtmlSanitizerSettings.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+
+namespace NexusReader.Infrastructure.Configuration;
+
+///
+/// Settings for configuring allowed tags, attributes, CSS properties, and schemes in HtmlSanitizerService.
+///
+public class HtmlSanitizerSettings
+{
+ public const string SectionName = "HtmlSanitizer";
+
+ ///
+ /// Gets or sets the list of HTML tags that are allowed.
+ /// If null or empty, the default allowed tags list is used.
+ ///
+ public List? AllowedTags { get; set; }
+
+ ///
+ /// Gets or sets the list of HTML attributes that are allowed.
+ /// If null or empty, the default allowed attributes list is used.
+ ///
+ public List? AllowedAttributes { get; set; }
+
+ ///
+ /// Gets or sets the list of CSS properties that are allowed.
+ /// If null or empty, the default allowed CSS properties list is used.
+ ///
+ public List? AllowedCssProperties { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public List? AllowedSchemes { get; set; }
+}
diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs
index 404d247..63cdc37 100644
--- a/src/NexusReader.Infrastructure/DependencyInjection.cs
+++ b/src/NexusReader.Infrastructure/DependencyInjection.cs
@@ -78,6 +78,7 @@ public static class DependencyInjection
services.Configure(configuration.GetSection(AiSettings.SectionName));
services.Configure(configuration.GetSection(StripeSettings.SectionName));
services.Configure(configuration.GetSection(RagMonetizationOptions.SectionName));
+ services.Configure(configuration.GetSection(HtmlSanitizerSettings.SectionName));
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get() ?? new AiSettings();
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
diff --git a/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs
index 753f6d6..a08c967 100644
--- a/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs
+++ b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs
@@ -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? 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)
diff --git a/src/NexusReader.Infrastructure/Services/LocalStorageService.cs b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs
index 2adad67..6b61ec5 100644
--- a/src/NexusReader.Infrastructure/Services/LocalStorageService.cs
+++ b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs
@@ -25,18 +25,29 @@ public class LocalStorageService : IStorageService
public async Task 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);
}
diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor
index 1f0b944..01ce06b 100644
--- a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor
+++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor
@@ -17,6 +17,7 @@
@code {
private readonly string EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
+ private readonly CancellationTokenSource _cts = new();
private IJSObjectReference? _module;
private DotNetObjectReference? _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.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)
diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css
index 2b1841a..87e124d 100644
--- a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css
+++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css
@@ -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;
+}
diff --git a/src/NexusReader.UI.Shared/Pages/Creator.razor b/src/NexusReader.UI.Shared/Pages/Creator.razor
index 6f9cbf8..c191997 100644
--- a/src/NexusReader.UI.Shared/Pages/Creator.razor
+++ b/src/NexusReader.UI.Shared/Pages/Creator.razor
@@ -1,6 +1,6 @@
@page "/creator"
@using Microsoft.AspNetCore.Authorization
-@attribute [AllowAnonymous]
+@attribute [Authorize]
Kreator Treści (Zen Mode)
diff --git a/src/NexusReader.UI.Shared/Pages/Creator.razor.css b/src/NexusReader.UI.Shared/Pages/Creator.razor.css
index 716c571..2801637 100644
--- a/src/NexusReader.UI.Shared/Pages/Creator.razor.css
+++ b/src/NexusReader.UI.Shared/Pages/Creator.razor.css
@@ -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;
}
diff --git a/src/NexusReader.UI.Shared/wwwroot/app.css b/src/NexusReader.UI.Shared/wwwroot/app.css
index 35d5c56..d34c309 100644
--- a/src/NexusReader.UI.Shared/wwwroot/app.css
+++ b/src/NexusReader.UI.Shared/wwwroot/app.css
@@ -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);