diff --git a/Directory.Packages.props b/Directory.Packages.props index 0f630a0..c9be3b9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + diff --git a/src/NexusReader.Application/Abstractions/Services/ISanitizerService.cs b/src/NexusReader.Application/Abstractions/Services/ISanitizerService.cs new file mode 100644 index 0000000..fa0dc91 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/ISanitizerService.cs @@ -0,0 +1,13 @@ +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 +{ + /// + /// Sanitizes the input string and returns a clean, safe version. + /// + string Sanitize(string input); +} diff --git a/src/NexusReader.Application/Abstractions/Services/IStorageService.cs b/src/NexusReader.Application/Abstractions/Services/IStorageService.cs new file mode 100644 index 0000000..a2d8d84 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IStorageService.cs @@ -0,0 +1,18 @@ +namespace NexusReader.Application.Abstractions.Services; + +/// +/// General file storage service interface for handling media uploads. +/// Intended to have a Scoped lifetime. +/// +public interface IStorageService +{ + /// + /// Uploads a file stream and returns its public URL/path. + /// + Task UploadFileAsync(Stream fileStream, string fileName, string contentType); + + /// + /// Uploads file bytes and returns its public URL/path. + /// + Task UploadFileAsync(byte[] fileBytes, string fileName, string contentType); +} diff --git a/src/NexusReader.Application/Common/AppJsonContext.cs b/src/NexusReader.Application/Common/AppJsonContext.cs index 387442f..a8323a4 100644 --- a/src/NexusReader.Application/Common/AppJsonContext.cs +++ b/src/NexusReader.Application/Common/AppJsonContext.cs @@ -18,6 +18,9 @@ namespace NexusReader.Application.Common; [JsonSerializable(typeof(List))] [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 { } diff --git a/src/NexusReader.Application/DTOs/Media/MediaDtos.cs b/src/NexusReader.Application/DTOs/Media/MediaDtos.cs new file mode 100644 index 0000000..8553e3a --- /dev/null +++ b/src/NexusReader.Application/DTOs/Media/MediaDtos.cs @@ -0,0 +1,18 @@ +namespace NexusReader.Application.DTOs.Media; + +// Note: These DTOs are registered in AppJsonContext.cs for JSON source generation. + +/// +/// Request DTO for chapter validation/sanitization. +/// +public record ValidateChapterRequest(string Content); + +/// +/// Response DTO containing sanitized chapter content. +/// +public record ValidateChapterResponse(string SanitizedContent); + +/// +/// Response DTO containing the uploaded media file URL. +/// +public record UploadResultDto(string Url); 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 13801ad..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") @@ -124,6 +125,8 @@ public static class DependencyInjection // Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution // that is environment-specific and incompatible with Singleton lifetime in MAUI. services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); // Fix #1: Ebook repository (scoped, matches AppDbContext lifetime) services.AddScoped(); diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index 0f5ea00..b423dfb 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -28,6 +28,7 @@ + diff --git a/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs new file mode 100644 index 0000000..a08c967 --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs @@ -0,0 +1,70 @@ +using Ganss.Xss; +using Microsoft.Extensions.Options; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Infrastructure.Configuration; + +namespace NexusReader.Infrastructure.Services; + +/// +/// Infrastructure implementation of ISanitizerService using the Ganss.Xss HtmlSanitizer library. +/// +public class HtmlSanitizerService : ISanitizerService +{ + private readonly HtmlSanitizer _sanitizer; + + public HtmlSanitizerService(IOptions? options = null) + { + _sanitizer = new HtmlSanitizer(); + + 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) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + return _sanitizer.Sanitize(input); + } +} diff --git a/src/NexusReader.Infrastructure/Services/LocalStorageService.cs b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs new file mode 100644 index 0000000..6b61ec5 --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Hosting; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Infrastructure.Services; + +/// +/// Infrastructure implementation of general storage utilizing local filesystem. +/// Files are saved in wwwroot/uploads/media. +/// +public class LocalStorageService : IStorageService +{ + private readonly IWebHostEnvironment _environment; + + public LocalStorageService(IWebHostEnvironment environment) + { + _environment = environment; + } + + public async Task UploadFileAsync(byte[] fileBytes, string fileName, string contentType) + { + using var stream = new MemoryStream(fileBytes); + return await UploadFileAsync(stream, fileName, contentType); + } + + 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(resolvedMediaFolder)) + { + 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(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(fullPath, FileMode.Create)) + { + await fileStream.CopyToAsync(outputStream); + } + + // Return the public web-relative URL + return $"/uploads/media/{uniqueFileName}"; + } +} diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor index b571496..f2114a2 100644 --- a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor @@ -101,6 +101,11 @@ break; + case "edit": + case "edit-2": + + + break; case "log-out": break; diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor new file mode 100644 index 0000000..01ce06b --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor @@ -0,0 +1,154 @@ +@using Microsoft.JSInterop +@implements IAsyncDisposable +@inject IJSRuntime JS +@inject HttpClient Http + +
+
+ @if (ShowFetchButton) + { +
+ +
+ } +
+ +@code { + private readonly string EditorId = $"milkdown-editor-{Guid.NewGuid():N}"; + private readonly CancellationTokenSource _cts = new(); + private IJSObjectReference? _module; + private DotNetObjectReference? _dotNetHelper; + + [Parameter] + public bool ShowFetchButton { get; set; } = true; + + [Parameter] + public string InitialMarkdown { get; set; } = string.Empty; + + [Parameter] + public EventCallback OnSave { get; set; } + + [Parameter] + public string Height { get; set; } = "500px"; + + [Parameter] + public string Width { get; set; } = "100%"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotNetHelper = DotNetObjectReference.Create(this); + try + { + // Import the isolated JavaScript module + _module = await JS.InvokeAsync( + "import", + "./_content/NexusReader.UI.Shared/js/milkdownWrapper.js" + ); + + // Call the initialization function in the wrapper + await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown); + } + catch (Exception ex) + { + // Log the exception gracefully and do not crash the component + Console.WriteLine($"[MarkdownEditor] Error initializing Milkdown editor: {ex.Message}"); + } + } + } + + public async Task FetchContentAsync() + { + if (_module is not null) + { + try + { + // Retrieve the updated markdown from JS + var markdown = await _module.InvokeAsync("getMarkdownContent", EditorId); + + if (OnSave.HasDelegate) + { + await OnSave.InvokeAsync(markdown); + } + } + catch (Exception ex) + { + Console.WriteLine($"[MarkdownEditor] Error fetching markdown content: {ex.Message}"); + } + } + } + + [JSInvokable] + public async Task 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, _cts.Token); + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync( + NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto, _cts.Token); + 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 + { + _cts.Cancel(); + _cts.Dispose(); + } + catch + { + // Fail silently if cancellation token disposal fails + } + + try + { + if (_module is not null) + { + // Clean up the JS editor instance to prevent memory leaks + await _module.InvokeVoidAsync("destroyEditor", EditorId); + await _module.DisposeAsync(); + } + } + catch (JSDisconnectedException) + { + // Fail silently during circuit disconnection + } + catch (ObjectDisposedException) + { + // Fail silently if JS runtime/module is already disposed + } + catch (Exception ex) + { + // Log other unexpected errors + Console.WriteLine($"[MarkdownEditor] Unexpected error during JS cleanup: {ex.Message}"); + } + finally + { + _dotNetHelper?.Dispose(); + } + } +} diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css new file mode 100644 index 0000000..87e124d --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css @@ -0,0 +1,86 @@ +.markdown-editor-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.milkdown-editor-wrapper { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-surface); + overflow: auto; + padding: 1.5rem; + position: relative; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.milkdown-editor-wrapper:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-glow); +} + +.editor-actions { + display: flex; + justify-content: flex-end; +} + +/* 3. Bypassing Blazor CSS Isolation for Dynamic JS DOMs using ::deep */ +::deep .milkdown-editor-wrapper .crepe { + max-width: 100% !important; +} + +::deep .milkdown-editor-wrapper .milkdown { + background-color: var(--bg-surface) !important; + color: var(--text-main) !important; + font-family: var(--nexus-font-sans) !important; + border: none !important; + box-shadow: none !important; + + /* Map Crepe's internal variables to our design tokens */ + --crepe-color-background: var(--bg-surface); + --crepe-color-on-background: var(--text-main); + --crepe-color-surface: rgba(255, 255, 255, 0.03); + --crepe-color-surface-low: rgba(255, 255, 255, 0.01); + --crepe-color-primary: var(--accent); + --crepe-color-outline: var(--border); +} + +::deep .milkdown-editor-wrapper .milkdown .editor { + color: var(--text-main) !important; + background: transparent !important; + outline: none !important; + padding: 0.5rem 0 !important; + min-height: 200px; +} + +/* Style the buttons using variables from app.css */ +.nexus-btn { + font-family: var(--nexus-font-sans); + font-weight: 600; + border-radius: var(--radius-md); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border: none; + text-decoration: none; + background: var(--nexus-neon); + color: #000000; + padding: 8px 16px; + font-size: 0.9rem; + min-height: 36px; +} + +.nexus-btn:hover { + transform: translateY(-2px); + 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/Layout/MainHubLayout.razor b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor index 5c84fec..a892ff7 100644 --- a/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor +++ b/src/NexusReader.UI.Shared/Layout/MainHubLayout.razor @@ -95,6 +95,12 @@ Koncentry + + + Kreator +