feat(infra): Docker-compose configuration and environment-specific security guards for Beta deployment to Test environment (#56)

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>
This commit was merged in pull request #56.
This commit is contained in:
2026-06-01 17:17:45 +00:00
committed by Marek Jaisński
parent 72905aa119
commit 711480f8f6
54 changed files with 4181 additions and 282 deletions
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging;
namespace NexusReader.UI.Shared.Services;
public sealed partial class KnowledgeCoordinator : IDisposable
public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
{
private readonly IKnowledgeService _knowledgeService;
private readonly IKnowledgeGraphService _graphService;
@@ -16,6 +16,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
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;
@@ -75,9 +78,38 @@ public sealed partial class KnowledgeCoordinator : IDisposable
}
}
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)) return;
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);
@@ -87,7 +119,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
try
{
var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId);
var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId, token);
token.ThrowIfCancellationRequested();
if (result.IsSuccess)
{
var packet = result.Value;
@@ -103,10 +137,17 @@ public sealed partial class KnowledgeCoordinator : IDisposable
await _graphService.SetLoading(false);
}
catch (OperationCanceledException)
{
_logger.LogInformation("[KnowledgeCoordinator] Graph generation task was canceled.");
}
catch (Exception ex)
{
await _graphService.SetLoading(false);
LogGraphError(ex, tenantId);
if (!token.IsCancellationRequested)
{
await _graphService.SetLoading(false);
LogGraphError(ex, tenantId);
}
}
}
@@ -118,11 +159,17 @@ public sealed partial class KnowledgeCoordinator : IDisposable
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);
var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId, cancellationToken: token);
token.ThrowIfCancellationRequested();
if (result.IsSuccess)
{
var packet = result.Value;
@@ -138,10 +185,19 @@ public sealed partial class KnowledgeCoordinator : IDisposable
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)
{
LogSummaryError(ex, tenantId);
return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(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
{
@@ -151,6 +207,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
public async Task ClearAsync()
{
CancelAndDisposeCts(ref _graphCts);
CancelAndDisposeCts(ref _quizCts);
CurrentFullPageContent = string.Empty;
await _graphService.Clear();
await _quizService.SetQuiz(null, null);
@@ -159,6 +218,27 @@ public sealed partial class KnowledgeCoordinator : IDisposable
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}")]