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
@@ -0,0 +1,367 @@
@using NexusReader.Application.DTOs.AI
@using NexusReader.Application.Abstractions.Services
@using NexusReader.Application.DTOs.User
@using NexusReader.UI.Shared.Components.Atoms
@using NexusReader.UI.Shared.Services
@using NexusReader.UI.Shared.Models
@using System.Net.Http.Json
@namespace NexusReader.UI.Shared.Components.Organisms
@inject HttpClient Http
@inject IKnowledgeService KnowledgeService
@inject IReaderNavigationService NavigationService
<div class="global-intelligence-sheet @(IsOpen ? "is-open" : "")">
<div class="sheet-backdrop" @onclick="HandleClose"></div>
<div class="sheet-content">
<div class="sheet-drag-handle"></div>
<header class="sheet-header">
<div class="header-main">
<div class="ai-avatar-badge">
<NexusIcon Name="robot" Size="20" Class="neon-glow" />
</div>
<div class="header-info">
<h3>Asystent AI Nexus</h3>
<p class="subtitle">Zadawaj pytania do swojej biblioteki</p>
</div>
</div>
<button class="close-btn" @onclick="HandleClose" aria-label="Zamknij">
<NexusIcon Name="close" Size="20" />
</button>
</header>
<div class="sheet-body">
<div class="chat-thread" id="mobile-chat-thread">
@if (_chatMessages.Count == 0)
{
<div class="welcome-container">
<div class="welcome-glow-icon">
<NexusIcon Name="brain" Size="32" Class="neon-glow" />
</div>
<h4>Zadaj pytanie asystentowi</h4>
<p>KM-RAG przeszukuje całą treść książki, wyciąga semantyczne powiązania i generuje precyzyjne odpowiedzi wraz z przypisami źródłowymi.</p>
</div>
}
else
{
@foreach (var message in _chatMessages)
{
<div class="message-row @(message.Sender == "User" ? "user-row" : "ai-row")" @key="message.Id">
<div class="message-avatar">
@if (message.Sender == "User")
{
<NexusIcon Name="user" Size="16" />
}
else
{
<NexusIcon Name="robot" Size="16" />
}
</div>
<div class="message-bubble @(message.Sender == "User" ? "user-bubble" : "ai-bubble")">
<div class="message-meta">
<span class="sender-name">@(message.Sender == "User" ? "Ty" : "Asystent")</span>
<span class="message-time">@message.Timestamp.ToString("HH:mm")</span>
</div>
<div class="message-text">
@foreach (var segment in message.Segments)
{
@if (segment.IsCitation)
{
<span class="nexus-mobile-citation" @onclick="() => HandleCitationClick(segment.CitationId)">
[@segment.CitationId]
</span>
}
else
{
@RenderMarkdown(segment.Text)
}
}
</div>
</div>
</div>
}
@if (_isLoading)
{
<div class="message-row ai-row">
<div class="message-avatar">
<NexusIcon Name="robot" Size="16" />
</div>
<div class="message-bubble ai-bubble pending-bubble">
<div class="message-meta">
<span class="sender-name">Asystent</span>
<span class="message-time">Generowanie...</span>
</div>
<div class="message-text">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
<span class="loading-label">Analiza grafu pojęć...</span>
</div>
</div>
</div>
}
}
</div>
</div>
<footer class="sheet-footer">
<div class="scope-indicator">
<NexusIcon Name="map" Size="12" />
<span>Obszar: <strong>@(string.IsNullOrEmpty(_activeBookTitle) ? "Cała biblioteka" : _activeBookTitle)</strong></span>
</div>
<div class="input-container">
<input type="text"
class="nexus-mobile-input"
placeholder="Zadaj pytanie..."
@bind="_question"
@bind:event="oninput"
@onkeyup="HandleKeyUp"
disabled="@_isLoading" />
<button class="send-btn @(string.IsNullOrWhiteSpace(_question) || _isLoading ? "disabled" : "")"
disabled="@(string.IsNullOrWhiteSpace(_question) || _isLoading)"
@onclick="AskQuestionAsync">
@if (_isLoading)
{
<div class="btn-spinner"></div>
}
else
{
<NexusIcon Name="send" Size="18" />
}
</button>
</div>
</footer>
@if (_selectedCitation != null)
{
<div class="citation-modal-overlay" @onclick="CloseCitationModal">
<div class="citation-modal glass-panel" @onclick:stopPropagation>
<div class="modal-header">
<span class="book-title"><NexusIcon Name="map" Size="14" /> @_selectedCitation.SourceBook</span>
<button class="close-btn" @onclick="CloseCitationModal" aria-label="Close">
<NexusIcon Name="close" Size="16" />
</button>
</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_selectedCitation.Author))
{
<p class="citation-author"><strong>Autor:</strong> @_selectedCitation.Author</p>
}
@if (_selectedCitation.PageNumber.HasValue)
{
<p class="citation-page"><strong>Strona:</strong> @_selectedCitation.PageNumber.Value</p>
}
<p class="citation-snippet">"@_selectedCitation.Snippet"</p>
</div>
<div class="modal-footer">
<button class="btn-nexus" @onclick="CloseCitationModal">Zamknij</button>
</div>
</div>
</div>
}
</div>
</div>
@code {
private static readonly System.Text.RegularExpressions.Regex CitationRegex = new(
@"\[Source ID:\s*([^\]]+)\]|\[([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})\]",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex BoldRegex = new(
@"\*\*(.*?)\*\*",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex ItalicRegex = new(
@"\*(.*?)\*",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex CodeBlockRegex = new(
@"```(?:[a-zA-Z0-9+#]+)?\s*([\s\S]*?)\s*```",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex InlineCodeRegex = new(
@"`(.*?)`",
System.Text.RegularExpressions.RegexOptions.Compiled);
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public string? TenantId { get; set; }
private string _question = string.Empty;
private bool _isLoading;
private string _activeBookTitle = string.Empty;
private List<ChatMessage> _chatMessages = new();
private CitationDto? _selectedCitation;
protected override async Task OnParametersSetAsync()
{
if (IsOpen && string.IsNullOrEmpty(_activeBookTitle) && NavigationService.CurrentEbookId != Guid.Empty)
{
_activeBookTitle = NavigationService.ChapterTitle ?? "Aktywna książka";
}
}
private async Task HandleClose()
{
if (OnClose.HasDelegate)
{
await OnClose.InvokeAsync();
}
}
private async Task HandleKeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_question) && !_isLoading)
{
await AskQuestionAsync();
}
}
private void HandleCitationClick(string citationId)
{
_selectedCitation = _chatMessages
.SelectMany(m => m.Citations)
.FirstOrDefault(c => c.CitationId.Equals(citationId, StringComparison.OrdinalIgnoreCase))
?? new CitationDto
{
CitationId = citationId,
SourceBook = "Grounded Document Chunk",
Snippet = "Context snippet retrieved from vector search node."
};
}
private void CloseCitationModal()
{
_selectedCitation = null;
}
private async Task AskQuestionAsync()
{
if (string.IsNullOrWhiteSpace(_question) || _isLoading) return;
var userQuestion = _question;
_question = string.Empty;
_isLoading = true;
_chatMessages.Add(new ChatMessage
{
Sender = "User",
Text = userQuestion,
Segments = new List<ResponseSegment> { new ResponseSegment { Text = userQuestion, IsCitation = false } }
});
StateHasChanged();
try
{
Guid? ebookId = null;
if (NavigationService.CurrentEbookId != Guid.Empty)
{
ebookId = NavigationService.CurrentEbookId;
}
var tenantId = TenantId ?? "global";
var result = await KnowledgeService.AskQuestionAsync(userQuestion, tenantId, ebookId);
if (result.IsSuccess)
{
var response = result.Value;
_chatMessages.Add(new ChatMessage
{
Sender = "AI",
Text = response.Answer,
Segments = ParseSegments(response.Answer),
Citations = response.Citations
});
}
else
{
var errMsg = $"Błąd: {result.Errors.FirstOrDefault()?.Message ?? "Wystąpił nieznany problem."}";
_chatMessages.Add(new ChatMessage
{
Sender = "AI",
Text = errMsg,
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
});
}
}
catch (Exception ex)
{
var errMsg = $"Błąd sieci/API: {ex.Message}";
_chatMessages.Add(new ChatMessage
{
Sender = "AI",
Text = errMsg,
Segments = new List<ResponseSegment> { new ResponseSegment { Text = errMsg, IsCitation = false } }
});
}
finally
{
_isLoading = false;
StateHasChanged();
}
}
private List<ResponseSegment> ParseSegments(string text)
{
var segments = new List<ResponseSegment>();
if (string.IsNullOrEmpty(text)) return segments;
var matches = CitationRegex.Matches(text);
int lastIndex = 0;
foreach (System.Text.RegularExpressions.Match match in matches)
{
if (match.Index > lastIndex)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex, match.Index - lastIndex),
IsCitation = false
});
}
var citationId = match.Groups[1].Success
? match.Groups[1].Value.Trim()
: match.Groups[2].Value.Trim();
segments.Add(new ResponseSegment
{
IsCitation = true,
CitationId = citationId
});
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
{
segments.Add(new ResponseSegment
{
Text = text.Substring(lastIndex),
IsCitation = false
});
}
return segments;
}
private MarkupString RenderMarkdown(string text)
{
if (string.IsNullOrEmpty(text)) return new MarkupString(string.Empty);
var html = System.Net.WebUtility.HtmlEncode(text);
html = BoldRegex.Replace(html, "<strong>$1</strong>");
html = ItalicRegex.Replace(html, "<em>$1</em>");
html = CodeBlockRegex.Replace(html, "<pre class=\"nexus-mobile-code-block\"><code>$1</code></pre>");
html = InlineCodeRegex.Replace(html, "<code class=\"nexus-mobile-inline-code\">$1</code>");
html = html.Replace("\n", "<br />");
return new MarkupString(html);
}
}