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);