feat(creator): overhaul Creator flow, editor duplication, and staging setup (#83)
This pull request completely overhauls the Creator editor flow, resolves the editor duplication race condition, aligns layout/styling themes in light and dark mode, and adds Docker staging setups. ### Key Changes 1. **Creator Flow Polish**: Redesigned the editor canvas to prevent double scrolling by delegating overflow to the editor canvas layer, updated styles to a premium aesthetic. 2. **Race Condition Prevention**: Resolved Crepe editor duplication when loading or switching chapters by tracking state via shared window maps (`window.editorCache`, `window.editorStates`) and checking `_lastInitializedEditorId` synchronously in Blazor. 3. **Theme Synchronization**: Integrated explicit theme initialization (`ThemeService.InitializeAsync()`) and anchored CSS isolation selectors to correctly sync with Light (Soft Sepia) and Deep Dark theme preferences. 4. **Staging Automation**: Created staging docker configurations with `--nexus-only` flag to allow iterative development without resetting PG/Neo4j database containers. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #83 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #83.
This commit is contained in:
+203
-27
@@ -91,6 +91,10 @@ builder.Services.AddCascadingAuthenticationState();
|
||||
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck<NexusReader.Web.Services.DatabaseHealthCheck>("Database")
|
||||
.AddCheck<NexusReader.Web.Services.QdrantHealthCheck>("Qdrant")
|
||||
.AddCheck<NexusReader.Web.Services.Neo4jHealthCheck>("Neo4j");
|
||||
|
||||
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
||||
NexusReader.Application.DependencyInjection.Assembly,
|
||||
@@ -295,6 +299,7 @@ if (!allowRegistration || !allowPasswordReset)
|
||||
}
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapHealthChecks("/health");
|
||||
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||
|
||||
// API endpoint for WASM client to fetch EPUB content
|
||||
@@ -493,6 +498,132 @@ app.MapGet("/api/library/books", async (ClaimsPrincipal user, IMediator mediator
|
||||
return Results.BadRequest(errorMsg);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/creator/dashboard", async (ClaimsPrincipal user, IMediator mediator) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||
|
||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||
|
||||
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetCreatorDashboardDataQuery(userId, tenantId));
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
|
||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||
return Results.BadRequest(errorMsg);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/creator/books/{bookId:guid}/revisions", async (Guid bookId, ClaimsPrincipal user, IMediator mediator) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||
|
||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||
|
||||
var result = await mediator.Send(new NexusReader.Application.Queries.Creator.GetBookRevisionsQuery(bookId, userId, tenantId));
|
||||
if (result.IsSuccess) return Results.Ok(result.Value);
|
||||
|
||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.NotFound(errorMsg);
|
||||
}
|
||||
return Results.BadRequest(errorMsg);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPost("/api/creator/books/{bookId:guid}/publish", async (Guid bookId, [FromQuery] string version, ClaimsPrincipal user, IMediator mediator) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||
|
||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return Results.BadRequest("Version string is required.");
|
||||
}
|
||||
|
||||
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.PublishBookVersionCommand(bookId, version, userId, tenantId));
|
||||
if (result.IsSuccess) return Results.Ok();
|
||||
|
||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||
if (errorMsg.Contains("was not found", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Results.NotFound(errorMsg);
|
||||
}
|
||||
return Results.BadRequest(errorMsg);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPost("/api/creator/books", async (
|
||||
[FromBody] NexusReader.Application.DTOs.Creator.CreateBookRequestDto request,
|
||||
ClaimsPrincipal user,
|
||||
IMediator mediator) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||
|
||||
var tenantId = user.FindFirstValue("TenantId") ?? "global";
|
||||
|
||||
var result = await mediator.Send(new NexusReader.Application.Features.Books.Commands.CreateBookCommand(
|
||||
request.Title,
|
||||
request.Description,
|
||||
userId,
|
||||
tenantId
|
||||
));
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
return Results.Ok(new NexusReader.Application.DTOs.Creator.CreateBookResponseDto(result.Value));
|
||||
}
|
||||
|
||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown server error";
|
||||
return Results.BadRequest(errorMsg);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/creator/books/{bookId:guid}/chapters", async (Guid bookId, ClaimsPrincipal user, IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
var book = await dbContext.Books
|
||||
.Include(b => b.CurrentDraftRevision)
|
||||
.ThenInclude(r => r!.Chapters)
|
||||
.FirstOrDefaultAsync(b => b.Id == bookId && b.UserId == userId);
|
||||
|
||||
if (book == null) return Results.NotFound();
|
||||
if (book.CurrentDraftRevision == null) return Results.BadRequest("No active draft revision.");
|
||||
|
||||
var chapters = book.CurrentDraftRevision.Chapters
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.Select(c => new { c.Id, c.Title, c.SortOrder })
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(chapters);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/chapters/{id:guid}", async (Guid id, ClaimsPrincipal user, IDbContextFactory<AppDbContext> dbContextFactory) =>
|
||||
{
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId)) return Results.Unauthorized();
|
||||
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var chapter = await dbContext.Chapters
|
||||
.Include(c => c.BookRevision)
|
||||
.ThenInclude(r => r.Book)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (chapter == null) return Results.NotFound();
|
||||
|
||||
// Verify ownership
|
||||
if (chapter.BookRevision.Book.UserId != userId)
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
return Results.Ok(new { chapter.Id, chapter.Title, chapter.MarkdownContent });
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapPost("/api/library/purchase", async (
|
||||
ClaimsPrincipal user,
|
||||
[FromBody] PurchaseBookRequest request,
|
||||
@@ -802,15 +933,15 @@ app.MapPost("/api/media/upload", async (
|
||||
fileBytes = memoryStream.ToArray();
|
||||
}
|
||||
|
||||
// Validate signature
|
||||
if (!ValidateImageSignature(fileBytes, file.ContentType))
|
||||
// Validate signature without trusting browser content-type, enforcing extension matching
|
||||
if (!ImageValidator.ValidateImageSignature(fileBytes, file.FileName, out var detectedContentType))
|
||||
{
|
||||
logger.LogWarning("File signature validation failed for file {FileName} with content type {ContentType}.", file.FileName, file.ContentType);
|
||||
return Results.BadRequest("Invalid image signature. Legitimate JPEG, PNG, or WEBP images only.");
|
||||
logger.LogWarning("File signature validation failed for file {FileName} with browser content type {ContentType}.", file.FileName, file.ContentType);
|
||||
return Results.BadRequest("Invalid file signature or extension mismatch. Legitimate JPEG, PNG, WEBP, or GIF images only.");
|
||||
}
|
||||
|
||||
// Save using IStorageService
|
||||
var fileUrl = await storageService.UploadFileAsync(fileBytes, file.FileName, file.ContentType);
|
||||
// Save using IStorageService with the verified content type
|
||||
var fileUrl = await storageService.UploadFileAsync(fileBytes, file.FileName, detectedContentType);
|
||||
return Results.Ok(new NexusReader.Application.DTOs.Media.UploadResultDto(fileUrl));
|
||||
}).DisableAntiforgery();
|
||||
|
||||
@@ -827,6 +958,27 @@ app.MapPost("/api/chapters/validate", (
|
||||
return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(sanitized));
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapPut("/api/chapters/{id:guid}/autosave", async (
|
||||
Guid id,
|
||||
[Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.Media.AutosaveChapterRequest request,
|
||||
IDbContextFactory<AppDbContext> dbContextFactory,
|
||||
ILoggerFactory loggerFactory) =>
|
||||
{
|
||||
var logger = loggerFactory.CreateLogger("ChaptersApi");
|
||||
logger.LogInformation("Autosaving chapter {ChapterId} with content length {Length}", id, request?.MarkdownContent?.Length ?? 0);
|
||||
|
||||
if (request == null) return Results.BadRequest("Request content cannot be null.");
|
||||
|
||||
using var dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
var chapter = await dbContext.Chapters.FindAsync(id);
|
||||
if (chapter == null) return Results.NotFound($"Chapter with ID '{id}' was not found.");
|
||||
|
||||
chapter.MarkdownContent = request.MarkdownContent;
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { Success = true });
|
||||
}).DisableAntiforgery();
|
||||
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.AddInteractiveWebAssemblyRenderMode()
|
||||
@@ -878,32 +1030,56 @@ async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider se
|
||||
}
|
||||
}
|
||||
|
||||
static bool ValidateImageSignature(byte[] bytes, string contentType)
|
||||
public static class ImageValidator
|
||||
{
|
||||
if (bytes.Length < 4) return false;
|
||||
|
||||
// Check PNG signature: 89 50 4E 47
|
||||
if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
|
||||
public static bool ValidateImageSignature(byte[] bytes, string fileName, out string detectedContentType)
|
||||
{
|
||||
return contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
detectedContentType = string.Empty;
|
||||
if (bytes.Length < 4) return false;
|
||||
|
||||
// Check JPEG signature: FF D8 FF
|
||||
if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
|
||||
{
|
||||
return contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||||
contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
// Check PNG signature: 89 50 4E 47
|
||||
if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
|
||||
{
|
||||
detectedContentType = "image/png";
|
||||
}
|
||||
// Check JPEG signature: FF D8 FF
|
||||
else if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
|
||||
{
|
||||
detectedContentType = "image/jpeg";
|
||||
}
|
||||
// Check WEBP signature: RIFF ... WEBP
|
||||
else if (bytes.Length >= 12 &&
|
||||
bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && // RIFF
|
||||
bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) // WEBP
|
||||
{
|
||||
detectedContentType = "image/webp";
|
||||
}
|
||||
// Check GIF signature: GIF87a or GIF89a
|
||||
else if (bytes.Length >= 6 &&
|
||||
bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 &&
|
||||
(bytes[4] == 0x37 || bytes[4] == 0x39) && bytes[5] == 0x61)
|
||||
{
|
||||
detectedContentType = "image/gif";
|
||||
}
|
||||
|
||||
// Check WEBP signature: RIFF ... WEBP
|
||||
if (bytes.Length >= 12 &&
|
||||
bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && // RIFF
|
||||
bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) // WEBP
|
||||
{
|
||||
return contentType.Equals("image/webp", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
if (string.IsNullOrEmpty(detectedContentType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
// Verify that the file extension matches the detected content type (extension-spoofing guard)
|
||||
var ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
var isMatch = detectedContentType switch
|
||||
{
|
||||
"image/png" => ext == ".png",
|
||||
"image/jpeg" => ext == ".jpg" || ext == ".jpeg",
|
||||
"image/webp" => ext == ".webp",
|
||||
"image/gif" => ext == ".gif",
|
||||
_ => false
|
||||
};
|
||||
|
||||
return isMatch;
|
||||
}
|
||||
}
|
||||
|
||||
public record KnowledgeRequest(string Text, Guid? EbookId = null);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using NexusReader.Data.Persistence;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Web.Services;
|
||||
|
||||
public class DatabaseHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly AppDbContext _dbContext;
|
||||
|
||||
public DatabaseHealthCheck(AppDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var canConnect = await _dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
if (canConnect)
|
||||
{
|
||||
return HealthCheckResult.Healthy("Database is accessible.");
|
||||
}
|
||||
return HealthCheckResult.Unhealthy("Cannot connect to the database.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Database health check failed with exception.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Neo4j.Driver;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Web.Services;
|
||||
|
||||
public class Neo4jHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IDriver _driver;
|
||||
|
||||
public Neo4jHealthCheck(IDriver driver)
|
||||
{
|
||||
_driver = driver;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _driver.VerifyConnectivityAsync();
|
||||
return HealthCheckResult.Healthy("Neo4j database is accessible.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Neo4j database connectivity check failed.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Qdrant.Client;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NexusReader.Web.Services;
|
||||
|
||||
public class QdrantHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly QdrantClient _qdrantClient;
|
||||
|
||||
public QdrantHealthCheck(QdrantClient qdrantClient)
|
||||
{
|
||||
_qdrantClient = qdrantClient;
|
||||
}
|
||||
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Simple check: query collection existence to verify connection is alive
|
||||
_ = await _qdrantClient.CollectionExistsAsync("knowledge_units", cancellationToken);
|
||||
return HealthCheckResult.Healthy("Qdrant database is accessible.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Qdrant database health check failed.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user