711480f8f6
This pull request introduces the dedicated containerized infrastructure and configuration for deploying NexusReader's beta version in the Test environment. ### Summary of Changes 1. **Docker Infrastructure & Secrets**: - **`docker-compose.test.yml`**: Configured dedicated database and auxiliary services (PostgreSQL 17, Qdrant, Neo4j) on isolated, non-standard ports to ensure zero conflict with the existing server configurations. - **`.env.test.template`**: Provided an environment variable template showing required setups, including mandatory database passwords, API keys, and admin custom passwords. - **`.gitignore`**: Excluded local `.env` files to prevent accidental commits of production or staging secrets. 2. **Database Hardening**: - Configured Neo4j with basic authentication (`IDriver` instantiation uses basic auth when credentials are provided in configuration). - Configured PostgreSQL to use mandatory authentication. - Configured the admin seeder (`DbInitializer.cs`) to dynamically use `NEXUS_ADMIN_PASSWORD` from environment variables, falling back to a default password in local Development only. 3. **Feature-Flagged Restrictions**: - **`appsettings.Test.json`**: Implemented `Features:AllowRegistration` and `Features:AllowPasswordReset` flags set to `false`. - **Middleware Enforcement (`Program.cs`)**: Intercepts requests to `/identity/register` and `/identity/forgotPassword` (and their MVC/form variations) and rejects them with a `403 Forbidden` response in restricted environments. - **OAuth Provisioning Guard (`Program.cs`)**: Blocks new account provisioning via Google OAuth callback by checking the `Features:AllowRegistration` configuration, redirecting users to the login page with a descriptive error. - **UI Protection (`Login.razor`, `Register.razor`)**: Conditionally hides registration/password reset links and intercepts manual navigation attempts to `/account/register` by redirecting to login with a warning. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #56 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
259 lines
9.0 KiB
C#
259 lines
9.0 KiB
C#
using NexusReader.Application.Abstractions.Services;
|
|
using FluentResults;
|
|
using NexusReader.Application.Queries.Graph;
|
|
using NexusReader.Application.Queries.Quiz;
|
|
using NexusReader.UI.Shared.Services;
|
|
using NexusReader.Application.DTOs.AI;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace NexusReader.UI.Shared.Services;
|
|
|
|
public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
|
|
{
|
|
private readonly IKnowledgeService _knowledgeService;
|
|
private readonly IKnowledgeGraphService _graphService;
|
|
private readonly IQuizStateService _quizService;
|
|
private readonly IPlatformService _platformService;
|
|
private readonly IReaderInteractionService _interactionService;
|
|
private readonly ILogger<KnowledgeCoordinator> _logger;
|
|
|
|
private CancellationTokenSource? _graphCts;
|
|
private CancellationTokenSource? _quizCts;
|
|
|
|
public string CurrentFullPageContent { get; private set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Raised when the knowledge graph has been updated with new data.
|
|
/// Subscribers must return a Task to enable proper async handling.
|
|
/// </summary>
|
|
public event Func<GraphDataDto, Task>? OnGraphUpdated;
|
|
|
|
public KnowledgeCoordinator(
|
|
IKnowledgeService knowledgeService,
|
|
IKnowledgeGraphService graphService,
|
|
IQuizStateService quizService,
|
|
IPlatformService platformService,
|
|
IReaderInteractionService interactionService,
|
|
ILogger<KnowledgeCoordinator> logger)
|
|
{
|
|
_knowledgeService = knowledgeService;
|
|
_graphService = graphService;
|
|
_quizService = quizService;
|
|
_platformService = platformService;
|
|
_interactionService = interactionService;
|
|
_logger = logger;
|
|
|
|
_interactionService.OnNodeSelected += HandleNodeSelected;
|
|
}
|
|
|
|
private async Task HandleNodeSelected(string nodeId)
|
|
{
|
|
string? targetBlockId = nodeId;
|
|
|
|
var graph = _graphService.CurrentGraphData;
|
|
if (graph != null)
|
|
{
|
|
var selectedNode = graph.Nodes.FirstOrDefault(n => n.Id == nodeId);
|
|
if (selectedNode != null && selectedNode.Group == "concept")
|
|
{
|
|
// Look for connected block nodes (group: "current") in the links
|
|
var connectedLinks = graph.Links.Where(l => l.Source == nodeId || l.Target == nodeId).ToList();
|
|
foreach (var link in connectedLinks)
|
|
{
|
|
var otherId = link.Source == nodeId ? link.Target : link.Source;
|
|
var otherNode = graph.Nodes.FirstOrDefault(n => n.Id == otherId);
|
|
if (otherNode != null && otherNode.Group == "current")
|
|
{
|
|
targetBlockId = otherId;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(targetBlockId))
|
|
{
|
|
await _interactionService.RequestScrollToBlock(targetBlockId);
|
|
await _interactionService.RequestHighlightBlock(targetBlockId);
|
|
}
|
|
}
|
|
|
|
private void CancelAndDisposeCts(ref CancellationTokenSource? cts)
|
|
{
|
|
var localCts = cts;
|
|
cts = null;
|
|
if (localCts != null)
|
|
{
|
|
try
|
|
{
|
|
localCts.Cancel();
|
|
}
|
|
catch (ObjectDisposedException) { }
|
|
finally
|
|
{
|
|
localCts.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global", Guid? ebookId = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fullContent))
|
|
{
|
|
CancelAndDisposeCts(ref _graphCts);
|
|
await _graphService.Clear();
|
|
await _graphService.SetLoading(false);
|
|
CurrentFullPageContent = string.Empty;
|
|
return;
|
|
}
|
|
|
|
CancelAndDisposeCts(ref _graphCts);
|
|
_graphCts = new CancellationTokenSource();
|
|
var token = _graphCts.Token;
|
|
|
|
CurrentFullPageContent = fullContent;
|
|
LogGeneratingGraph(tenantId);
|
|
|
|
await _graphService.Clear();
|
|
await _graphService.SetLoading(true);
|
|
|
|
try
|
|
{
|
|
var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId, token);
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
if (result.IsSuccess)
|
|
{
|
|
var packet = result.Value;
|
|
if (packet.Graph != null)
|
|
{
|
|
await _graphService.UpdateGraph(packet.Graph);
|
|
if (OnGraphUpdated != null)
|
|
await OnGraphUpdated.Invoke(packet.Graph);
|
|
await _platformService.VibrateSuccessAsync();
|
|
return;
|
|
}
|
|
}
|
|
|
|
await _graphService.SetLoading(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_logger.LogInformation("[KnowledgeCoordinator] Graph generation task was canceled.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!token.IsCancellationRequested)
|
|
{
|
|
await _graphService.SetLoading(false);
|
|
LogGraphError(ex, tenantId);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task OnBlockReachedAsync(string blockId, string content)
|
|
{
|
|
// Only update active node for "TU JESTEŚ" logic, do NOT trigger highlight here
|
|
await _graphService.SetActiveNode(blockId);
|
|
}
|
|
|
|
public async Task<Result<KnowledgePacket>> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
|
|
{
|
|
CancelAndDisposeCts(ref _quizCts);
|
|
_quizCts = new CancellationTokenSource();
|
|
var token = _quizCts.Token;
|
|
|
|
await _quizService.SetHydrating(true);
|
|
LogRequestingSummary(tenantId);
|
|
try
|
|
{
|
|
var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId, cancellationToken: token);
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
if (result.IsSuccess)
|
|
{
|
|
var packet = result.Value;
|
|
var quizQuestions = packet.Quizzes
|
|
.Select(q => new QuizQuestionDto(q.Question, q.Options, q.CorrectIndex))
|
|
.ToList();
|
|
|
|
await _quizService.SetQuiz(null, new QuizDto(quizQuestions));
|
|
await _platformService.VibrateSuccessAsync();
|
|
return Result.Ok(packet);
|
|
}
|
|
|
|
LogSummaryWarning(tenantId);
|
|
return Result.Fail(result.Errors);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_logger.LogInformation("[KnowledgeCoordinator] Quiz and summary generation task was canceled.");
|
|
return Result.Fail("Task canceled");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (!token.IsCancellationRequested)
|
|
{
|
|
LogSummaryError(ex, tenantId);
|
|
return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex));
|
|
}
|
|
return Result.Fail("Task canceled");
|
|
}
|
|
finally
|
|
{
|
|
await _quizService.SetHydrating(false);
|
|
}
|
|
}
|
|
|
|
public async Task ClearAsync()
|
|
{
|
|
CancelAndDisposeCts(ref _graphCts);
|
|
CancelAndDisposeCts(ref _quizCts);
|
|
|
|
CurrentFullPageContent = string.Empty;
|
|
await _graphService.Clear();
|
|
await _quizService.SetQuiz(null, null);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_interactionService.OnNodeSelected -= HandleNodeSelected;
|
|
|
|
CancelAndDisposeCts(ref _graphCts);
|
|
CancelAndDisposeCts(ref _quizCts);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
_interactionService.OnNodeSelected -= HandleNodeSelected;
|
|
|
|
CancelAndDisposeCts(ref _graphCts);
|
|
CancelAndDisposeCts(ref _quizCts);
|
|
|
|
try
|
|
{
|
|
await _graphService.Clear();
|
|
await _quizService.SetQuiz(null, null);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Error clearing services during KnowledgeCoordinator disposal.");
|
|
}
|
|
}
|
|
|
|
[LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Generating full page graph for tenant: {TenantId}")]
|
|
private partial void LogGeneratingGraph(string tenantId);
|
|
|
|
[LoggerMessage(Level = LogLevel.Error, Message = "[KnowledgeCoordinator] Error generating graph for tenant: {TenantId}")]
|
|
private partial void LogGraphError(Exception ex, string tenantId);
|
|
|
|
[LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Requesting summary and quiz for tenant: {TenantId}")]
|
|
private partial void LogRequestingSummary(string tenantId);
|
|
|
|
[LoggerMessage(Level = LogLevel.Warning, Message = "[KnowledgeCoordinator] Failed to get summary and quiz for tenant: {TenantId}")]
|
|
private partial void LogSummaryWarning(string tenantId);
|
|
|
|
[LoggerMessage(Level = LogLevel.Error, Message = "[KnowledgeCoordinator] Error requesting summary and quiz for tenant: {TenantId}")]
|
|
private partial void LogSummaryError(Exception ex, string tenantId);
|
|
}
|