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