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
@@ -2,8 +2,9 @@
@using NexusReader.Application.Queries.Reader
@using Microsoft.JSInterop
@using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using Microsoft.AspNetCore.Components.Authorization
@implements IDisposable
@implements IAsyncDisposable
@inject IMediator Mediator
@inject IJSRuntime JS
@inject IThemeService ThemeService
@@ -11,11 +12,36 @@
@inject IReaderNavigationService NavigationService
@inject KnowledgeCoordinator Coordinator
@inject IReaderInteractionService InteractionService
@inject IReaderStateService StateService
@inject ISyncService SyncService
@inject AuthenticationStateProvider AuthStateProvider
@inject IQuizStateService QuizService
@inject IPlatformService PlatformService
@inject NavigationManager Navigation
@inject ILogger<ReaderCanvas> Logger
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
@if (_isMobile && ViewModel != null)
{
<header class="nexus-mobile-reader-header">
<button class="nexus-mobile-escape-btn" @onclick="HandleEscape" aria-label="Powrót do pulpitu">
<NexusIcon Name="chevron-left" Size="18" />
<span>Pulpit</span>
</button>
<div class="nexus-mobile-chapter-navigation">
<button class="nexus-chapter-nav-btn prev" @onclick="NavigationService.GoToPreviousChapter" disabled="@(NavigationService.CurrentChapterIndex == 0)" aria-label="Poprzedni rozdział">
<NexusIcon Name="chevron-left" Size="14" />
</button>
<div class="nexus-mobile-chapter-title">
@ViewModel.ChapterTitle
</div>
<button class="nexus-chapter-nav-btn next" @onclick="NavigationService.GoToNextChapter" disabled="@(NavigationService.CurrentChapterIndex >= NavigationService.TotalChapters - 1)" aria-label="Następny rozdział">
<NexusIcon Name="chevron-right" Size="14" />
</button>
</div>
</header>
}
@if (ViewModel == null)
{
<div class="loading-state full-page">
@@ -53,6 +79,8 @@
BlockId="@_selectedBlockId"
Coordinates="@_selectionCoords"
FullPageContent="@GetFullPageContent()" />
</div>
@code {
@@ -68,17 +96,31 @@
private ElementReference _containerRef;
private bool _isInteractive;
private string? _currentActiveBlockId;
private bool _isMobile = false;
private DotNetObjectReference<ReaderCanvas>? _selfReference;
private IJSObjectReference? _viewportModule;
protected override async Task OnInitializedAsync()
{
await Coordinator.ClearAsync();
ThemeService.OnThemeChanged += HandleUpdate;
NavigationService.OnNavigationChanged += OnNavigationChanged;
QuizService.OnQuizUpdated += HandleUpdate;
InteractionService.OnScrollToBlockRequested += HandleScrollRequested;
InteractionService.OnHighlightBlockRequested += HandleHighlightRequested;
InteractionService.OnTextSelected += HandleTextSelected;
SyncService.OnProgressReceived += HandleSyncProgressReceived;
var context = PlatformService.GetDeviceContext();
if (context.IsSuccess)
{
_isMobile = context.Value.DeviceType switch
{
DeviceType.Phone or DeviceType.Tablet => true,
_ => false
};
}
}
protected override async Task OnParametersSetAsync()
@@ -99,12 +141,15 @@
{
if (firstRender)
{
_selfReference = DotNetObjectReference.Create(this);
await SyncService.InitializeAsync();
_isInteractive = true;
if (ViewModel != null)
{
await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId);
}
await InitViewportDetectionAsync();
}
if (ViewModel != null && !_isJsInitialized)
@@ -115,12 +160,52 @@
}
}
private async ValueTask<IJSObjectReference> EnsureViewportModuleAsync()
{
if (_viewportModule == null)
{
_viewportModule = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/viewport.js");
}
return _viewportModule;
}
private async Task InitViewportDetectionAsync()
{
try
{
var module = await EnsureViewportModuleAsync();
var isMobileViewport = await module.InvokeAsync<bool>("isMobileViewport");
await OnViewportChanged(isMobileViewport);
if (_selfReference != null)
{
await module.InvokeVoidAsync("registerViewportObserver", _selfReference);
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to initialize viewport detection in ReaderCanvas.");
}
}
[JSInvokable]
public async Task OnViewportChanged(bool isMobile)
{
if (_isMobile != isMobile)
{
_isMobile = isMobile;
await InvokeAsync(StateHasChanged);
}
}
private async Task InitializeSelectionListenerAsync()
{
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/selectionHandler.js");
await module.InvokeVoidAsync("initSelectionListener", DotNetObjectReference.Create(this), _containerRef);
if (_selfReference != null)
{
await module.InvokeVoidAsync("initSelectionListener", _selfReference, _containerRef);
}
}
catch (Exception ex)
{
@@ -128,12 +213,18 @@
}
}
private IJSObjectReference? _scrollListenerReference;
private async Task InitializeObserverAsync()
{
try
{
var module = await JS.InvokeAsync<IJSObjectReference>("import", "./_content/NexusReader.UI.Shared/js/readerObserver.js");
await module.InvokeVoidAsync("initObserver", DotNetObjectReference.Create(this), ".reader-flow-container", ".block-wrapper");
if (_selfReference != null)
{
await module.InvokeVoidAsync("initObserver", _selfReference, ".reader-flow-container", ".block-wrapper");
_scrollListenerReference = await module.InvokeAsync<IJSObjectReference>("initScrollListener", _selfReference, ".reader-flow-container");
}
}
catch (Exception ex)
{
@@ -141,10 +232,19 @@
}
}
[JSInvokable]
public async Task HandleScrollPercentChanged(int percent)
{
StateService.CurrentScrollPercentage = percent;
await InteractionService.NotifyScrollPercentChanged(percent);
}
[JSInvokable]
public async Task HandleBlockReached(string blockId, string content)
{
_currentActiveBlockId = blockId;
StateService.CurrentBlockId = blockId;
await InteractionService.NotifyBlockReached(blockId);
await Coordinator.OnBlockReachedAsync(blockId, content);
if (ViewModel != null)
@@ -244,6 +344,13 @@
ViewModel = result.Value;
await NavigationService.UpdateMetadataAsync(ViewModel.CurrentChapterIndex, ViewModel.TotalChapters, ViewModel.ChapterTitle);
// Populate checkpoints!
var checkpoints = ViewModel.Blocks
.Where(b => !string.IsNullOrEmpty(b.Id) && b.Id.Contains("seg"))
.Select(b => b.Id)
.ToList();
StateService.CurrentCheckpoints = checkpoints;
if (_isInteractive)
{
await Coordinator.ProcessFullPageAsync(GetFullPageContent(), ebookId: ViewModel.EbookId);
@@ -259,16 +366,25 @@
_isLoadingChapter = false;
StateHasChanged();
if (result.IsSuccess && !string.IsNullOrEmpty(NavigationService.PendingScrollBlockId))
if (result.IsSuccess)
{
var targetBlockId = NavigationService.PendingScrollBlockId;
NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
_currentActiveBlockId = targetBlockId;
if (!string.IsNullOrEmpty(NavigationService.PendingScrollBlockId))
{
var targetBlockId = NavigationService.PendingScrollBlockId;
NavigationService.PendingScrollBlockId = null; // Clear it to prevent multiple scrolls
_currentActiveBlockId = targetBlockId;
// Give the browser slightly more than one frame to render the loaded blocks
await Task.Delay(150);
await ScrollToNodeAsync(targetBlockId);
await InteractionService.RequestHighlightBlock(targetBlockId);
// Give the browser slightly more than one frame to render the loaded blocks
await Task.Delay(150);
await ScrollToNodeAsync(targetBlockId);
await InteractionService.RequestHighlightBlock(targetBlockId);
}
else
{
// Reset scroll to top now that the new content DOM is rendered
await Task.Delay(50); // Give the browser a frame to render the new chapter content
await ScrollToTopAsync();
}
}
}
@@ -276,7 +392,8 @@
{
try
{
await JS.InvokeVoidAsync("eval", $"document.getElementById('{id}')?.scrollIntoView({{ behavior: 'smooth', block: 'center' }});");
var module = await EnsureViewportModuleAsync();
await module.InvokeVoidAsync("scrollIntoView", id);
}
catch (Exception ex)
{
@@ -284,16 +401,73 @@
}
}
public async Task ScrollToTopAsync()
{
try
{
var module = await EnsureViewportModuleAsync();
await module.InvokeVoidAsync("scrollToTop", ".reader-canvas");
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to scroll reader canvas to top.");
}
}
private Task HandleUpdate() => InvokeAsync(StateHasChanged);
public void Dispose()
private void HandleEscape()
{
if (ViewModel != null)
{
Navigation.NavigateTo("/");
}
}
private async Task HandleAssistantFabClick()
{
await InteractionService.RequestAssistant();
}
public async ValueTask DisposeAsync()
{
ThemeService.OnThemeChanged -= HandleUpdate;
NavigationService.OnNavigationChanged -= OnNavigationChanged;
QuizService.OnQuizUpdated -= HandleUpdate;
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
InteractionService.OnTextSelected -= HandleTextSelected;
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
try
{
if (_viewportModule != null)
{
if (_selfReference != null)
{
await _viewportModule.InvokeVoidAsync("unregisterViewportObserver", _selfReference);
}
await _viewportModule.DisposeAsync();
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Teardown of viewport observer module failed in ReaderCanvas disposal.");
}
try
{
if (_scrollListenerReference != null)
{
await _scrollListenerReference.DisposeAsync();
}
}
catch (Exception ex)
{
Logger.LogDebug(ex, "Teardown of scroll listener reference failed in ReaderCanvas disposal.");
}
_selfReference?.Dispose();
}
}