a0bf6c15f4
Resolves #52 This Pull Request introduces the **NexusSearchBox** search feature with premium unified styling, implements a robust **dynamic Qdrant collection auto-provisioning and batch-vector ingestion pipeline**, integrates a unified **Serilog logging infrastructure** for the Blazor Hybrid environment (MAUI), and resolves the **401 Unauthorized API header propagation error** inside mobile builds. ### 🚀 Key Implementations #### 1. Premium `NexusSearchBox` & Semantic Search UI * **NexusSearchBox Component:** Created an elegant search-as-you-type search box with smooth key navigation, quick-clearing, and seamless dynamic styling. * **Unified Aesthetics:** Refactored the search box isolated styling to align perfectly with the dashboard's design system using glassmorphism, `--nexus-neon` token gradients, and smooth pulse/fade animations. * **Semantic Search Integration:** Integrated semantic search query dispatching (`SearchLibrarySemanticallyQuery`) and wired up navigation seamlessly through the updated `ReaderNavigationService`. * **Tests Hardening:** Added/adapted query assertions in `QueryTests.cs` to guarantee safe parameterization and error boundary mapping. #### 2. Qdrant Collection Provisioning & Vector Ingestion * **Dynamic Auto-Provisioning:** Implemented dynamic checking and lazy-creation of the `knowledge_units` collection using 768 dimensions and Cosine distance. * **High-Performance Ingestion:** Optimized `ProcessKnowledgeUnitsAsync` with high-performance batch embedding generation using `_embeddingGenerator` and deterministic MD5 GUIDs for stable, duplicate-free upsertion. * **Database Cache Clear Sync:** Integrated Qdrant collection deletion in `ClearCacheAsync` to ensure absolute consistency between the PostgreSQL database cache and vector database indices. #### 3. Cross-Platform MAUI Logging (Serilog Infrastructure) * **Serilog Integration:** Configured cross-platform Serilog routing in `SerilogConfiguration.cs`, streaming diagnostic logs safely across native platforms and the Blazor Webview container. * **Interop Bridge:** Built `BlazorLoggingBridge.cs` to capture web console messages and pipe them directly to the native host logger. * **Demo Interface:** Added an interactive `SerilogDemo.razor` sandbox under Pages. #### 4. Resolving 401 Load Errors (Authentication Handler Flow) * **Authentication Header Handler:** Implemented the `MobileAuthenticationHeaderHandler` to correctly extract, validate, and inject bearer JWT tokens into outbound API requests. * **Configuration-based API Host:** Structured standard API URI routing to use clean configuration bindings in `appsettings.json`. --- ### 🧪 Verification & Build Status * Run `dotnet build` from the solution root: Successfully compiled the full multi-targeted solution (`Liczba błędów: 0`). * All unit and integration tests successfully executed and verified (`dotnet test`). --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Co-authored-by: Marek Jaisński <jasins.marek@gmail.com> Reviewed-on: #51 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
145 lines
5.8 KiB
C#
145 lines
5.8 KiB
C#
using System.Net.Http.Headers;
|
|
using System.Threading;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NexusReader.Application.Abstractions.Services;
|
|
|
|
namespace NexusReader.Maui.Infrastructure.Identity;
|
|
|
|
/// <summary>
|
|
/// A secure HTTP message delegating handler for MAUI that automatically appends JWT tokens
|
|
/// to trusted origin requests and transparently refreshes expired tokens in a thread-safe manner.
|
|
/// </summary>
|
|
public class MobileAuthenticationHeaderHandler : DelegatingHandler
|
|
{
|
|
private readonly INativeStorageService _storageService;
|
|
private readonly IServiceProvider _serviceProvider;
|
|
private const string TokenKey = "nexus_auth_token";
|
|
private static readonly SemaphoreSlim _refreshSemaphore = new(1, 1);
|
|
|
|
public MobileAuthenticationHeaderHandler(INativeStorageService storageService, IServiceProvider serviceProvider)
|
|
{
|
|
_storageService = storageService;
|
|
_serviceProvider = serviceProvider;
|
|
}
|
|
|
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
var path = request.RequestUri?.AbsolutePath ?? "";
|
|
bool isAuthEndpoint = path.Contains("identity/login") ||
|
|
path.Contains("identity/register") ||
|
|
path.Contains("identity/refresh");
|
|
|
|
// Resolve configured API host dynamically to avoid hardcoded IP addresses
|
|
var config = _serviceProvider.GetRequiredService<IConfiguration>();
|
|
var apiBaseUrlString = config["ApiSettings:BaseUrl"];
|
|
string? apiHost = null;
|
|
if (!string.IsNullOrEmpty(apiBaseUrlString) && Uri.TryCreate(apiBaseUrlString, UriKind.Absolute, out var apiUri))
|
|
{
|
|
apiHost = apiUri.Host;
|
|
}
|
|
|
|
// In MAUI, since we only call our own local or staging APIs, we trust local IP/localhost/configured API host.
|
|
// We ensure we don't accidentally leak tokens to third-party endpoints.
|
|
bool isTrustedHost = request.RequestUri != null &&
|
|
(request.RequestUri.Host == "localhost" ||
|
|
request.RequestUri.Host == "127.0.0.1" ||
|
|
(apiHost != null && request.RequestUri.Host == apiHost) ||
|
|
request.RequestUri.Host.EndsWith("nexusreader.com")); // Or staging domains
|
|
|
|
string? originalToken = null;
|
|
|
|
if (!isAuthEndpoint && isTrustedHost)
|
|
{
|
|
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
|
if (tokenResult.IsSuccess && !string.IsNullOrEmpty(tokenResult.Value))
|
|
{
|
|
originalToken = tokenResult.Value;
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", originalToken);
|
|
}
|
|
}
|
|
|
|
var response = await base.SendAsync(request, cancellationToken);
|
|
|
|
// Transparent JWT Auto-Refresh on 401 Unauthorized
|
|
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && !isAuthEndpoint)
|
|
{
|
|
await _refreshSemaphore.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
// Re-read token to verify if another concurrent request already refreshed it
|
|
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
|
var currentToken = tokenResult.IsSuccess ? tokenResult.Value : null;
|
|
|
|
bool refreshed = false;
|
|
|
|
if (!string.IsNullOrEmpty(currentToken) && currentToken != originalToken)
|
|
{
|
|
refreshed = true;
|
|
}
|
|
else
|
|
{
|
|
using var scope = _serviceProvider.CreateScope();
|
|
var identityService = scope.ServiceProvider.GetRequiredService<IIdentityService>();
|
|
var refreshResult = await identityService.RefreshTokenAsync();
|
|
if (refreshResult.IsSuccess)
|
|
{
|
|
var newTokenResult = await _storageService.GetSecureString(TokenKey);
|
|
currentToken = newTokenResult.IsSuccess ? newTokenResult.Value : null;
|
|
refreshed = !string.IsNullOrEmpty(currentToken);
|
|
}
|
|
else
|
|
{
|
|
await identityService.LogoutAsync();
|
|
}
|
|
}
|
|
|
|
if (refreshed && !string.IsNullOrEmpty(currentToken))
|
|
{
|
|
var newRequest = await CloneHttpRequestMessageAsync(request);
|
|
newRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", currentToken);
|
|
return await base.SendAsync(newRequest, cancellationToken);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Serilog.Log.Error(ex, "[MobileAuthHandler] Automated token renewal failed");
|
|
}
|
|
finally
|
|
{
|
|
_refreshSemaphore.Release();
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
private async Task<HttpRequestMessage> CloneHttpRequestMessageAsync(HttpRequestMessage req)
|
|
{
|
|
var clone = new HttpRequestMessage(req.Method, req.RequestUri)
|
|
{
|
|
Version = req.Version
|
|
};
|
|
|
|
if (req.Content != null)
|
|
{
|
|
var ms = new System.IO.MemoryStream();
|
|
await req.Content.CopyToAsync(ms);
|
|
ms.Position = 0;
|
|
clone.Content = new StreamContent(ms);
|
|
|
|
foreach (var h in req.Content.Headers)
|
|
{
|
|
clone.Content.Headers.TryAddWithoutValidation(h.Key, h.Value);
|
|
}
|
|
}
|
|
|
|
foreach (var h in req.Headers)
|
|
{
|
|
clone.Headers.TryAddWithoutValidation(h.Key, h.Value);
|
|
}
|
|
|
|
return clone;
|
|
}
|
|
}
|