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
+86 -3
View File
@@ -48,11 +48,15 @@ builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
builder.Services.AddScoped<IThemeService, ThemeService>();
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
builder.Services.AddSingleton(featureSettings);
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
builder.Services.AddScoped<KnowledgeCoordinator>();
builder.Services.AddScoped<ISyncService, SyncService>();
@@ -252,6 +256,35 @@ app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
// Feature flags: block registration & password reset in restricted environments (e.g. Test)
var allowRegistration = app.Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
var allowPasswordReset = app.Configuration.GetValue<bool?>("Features:AllowPasswordReset") ?? true;
if (!allowRegistration || !allowPasswordReset)
{
app.Use(async (context, next) =>
{
var path = context.Request.Path.Value?.ToLowerInvariant();
if (!allowRegistration && path is "/identity/register" or "/account/register")
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Registration is disabled in this environment.");
return;
}
if (!allowPasswordReset && path is "/identity/forgotpassword" or "/account/forgot-password")
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync("Password reset is disabled in this environment.");
return;
}
await next();
});
}
app.MapStaticAssets();
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
@@ -267,6 +300,50 @@ app.MapGet("/api/epub/{ebookId:guid}/{index:int}", async (Guid ebookId, int inde
return Results.BadRequest(errorMsg);
}).RequireAuthorization();
// API endpoint for WASM client/browser to fetch EPUB static resources (images, etc.)
app.MapGet("/api/epub/{ebookId:guid}/resource", async (Guid ebookId, string path, IEpubReader epubService, ClaimsPrincipal user, HttpContext httpContext, CancellationToken cancellationToken) =>
{
if (string.IsNullOrEmpty(path))
{
return Results.BadRequest("Path parameter is required.");
}
var decodedPath = Uri.UnescapeDataString(path);
if (decodedPath.Contains("..") || decodedPath.Contains(":") || decodedPath.StartsWith("/") || decodedPath.StartsWith("\\"))
{
return Results.BadRequest("Invalid resource path.");
}
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var result = await epubService.GetEpubResourceAsync(ebookId, decodedPath, userId, cancellationToken);
if (result.IsSuccess)
{
// Serve with client-side caching to avoid redundant roundtrips on chapter navigation
httpContext.Response.Headers.CacheControl = "public, max-age=86400";
var ext = Path.GetExtension(decodedPath).ToLowerInvariant();
var contentType = ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".svg" => "image/svg+xml",
".webp" => "image/webp",
".css" => "text/css",
".otf" => "font/otf",
".ttf" => "font/ttf",
".woff" => "font/woff",
".woff2" => "font/woff2",
_ => "application/octet-stream"
};
return Results.File(result.Value, contentType);
}
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Resource not found";
return Results.NotFound(errorMsg);
}).RequireAuthorization();
var knowledgeApi = app.MapGroup("/api/knowledge")
.RequireAuthorization("HasAvailableTokens")
.DisableAntiforgery();
@@ -489,7 +566,7 @@ app.MapGet("/identity/login/google", (string? returnUrl) =>
var properties = new AuthenticationProperties
{
RedirectUri = "/identity/callback/google",
Items = { { "returnUrl", returnUrl ?? "/" } }
Items = { { "returnUrl", string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl } }
};
return Results.Challenge(properties, new[] { "Google" });
});
@@ -520,7 +597,13 @@ app.MapGet("/identity/callback/google", async (
return Results.Redirect("/account/login?error=LockedOut");
}
// New user provisioning
// New user provisioning (blocked when registration is disabled)
if (!allowRegistration)
{
logger.LogWarning("Google provisioning blocked: registration is disabled in this environment.");
return Results.Redirect("/account/login?error=RegistrationDisabled");
}
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
if (email != null)
{
@@ -563,7 +646,7 @@ app.MapPost("/account/login-form", async (
if (result.Succeeded)
{
logger.LogInformation("User logged in: {Email}", email);
return Results.Redirect(returnUrl ?? "/");
return Results.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
}
var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials";
+13
View File
@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Features": {
"AllowRegistration": false,
"AllowPasswordReset": false
},
"ApiBaseUrl": "http://localhost:5000"
}