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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user