Adds 2 units and docs for unit 3. nfr-requirements plan

This commit is contained in:
2026-06-29 22:18:37 +02:00
parent 0e01ca1e1c
commit c156107cb1
126 changed files with 15204 additions and 80199 deletions
@@ -1,9 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Modules;
using SlpModularCms.Modules.Availability.Data;
using SlpModularCms.Modules.Availability.Middleware;
using SlpModularCms.Modules.Availability.Repositories;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability;
@@ -18,10 +22,25 @@ public class AvailabilityModule : IModule
{
services.AddScoped<IAvailabilityService, PersistentAvailabilityService>();
services.AddScoped<PersistentAvailabilityService>();
services.AddDbContext<AvailabilityDbContext>((serviceProvider, options) =>
{
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"));
});
services.AddDataProtection();
services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>();
services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>();
services.AddScoped<MasterAvailabilityServiceDependencies>();
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
}
public void UseModule(IApplicationBuilder app)
{
using var scope = app.ApplicationServices.CreateScope();
scope.ServiceProvider.GetRequiredService<AvailabilityDbContext>().Database.Migrate();
app.UseMiddleware<AvailabilityMiddleware>();
}
}
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Mvc;
using SlpModularCms.Modules.Availability.Models;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Controllers;
[ApiController]
[Route("[controller]")]
public class MasterController : ControllerBase
{
private readonly IMasterAvailabilityService _svc;
public MasterController(IMasterAvailabilityService svc) => _svc = svc;
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterMasterRequest request)
{
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
var success = await _svc.RegisterAsync(request.MasterUrl, apiKey);
return success ? Ok() : Unauthorized();
}
[HttpPost("status")]
public async Task<IActionResult> PushStatus([FromBody] PushStatusRequest request)
{
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
var success = await _svc.PushStatusAsync(apiKey, request.IsAvailable, request.DisableMessage);
return success ? Ok() : Unauthorized();
}
[HttpGet("registered-url")]
public async Task<IActionResult> GetRegisteredUrl()
{
var apiKey = Request.Headers["X-Master-Api-Key"].FirstOrDefault();
if (string.IsNullOrEmpty(apiKey)) return Unauthorized();
var url = await _svc.GetRegisteredUrlAsync(apiKey);
return url is not null ? Ok(new { MasterUrl = url }) : Unauthorized();
}
}
@@ -0,0 +1,25 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Modules.Availability.Data.Entities;
namespace SlpModularCms.Modules.Availability.Data;
[ExcludeFromCodeCoverage]
public class AvailabilityDbContext(DbContextOptions<AvailabilityDbContext> options) : DbContext(options)
{
public DbSet<MasterRegistration> MasterRegistrations => Set<MasterRegistration>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<MasterRegistration>(entity =>
{
entity.ToTable("AvailabilityMasterRegistrations");
entity.HasKey(e => e.Id);
entity.Property(e => e.MasterUrl).IsRequired().HasMaxLength(500);
entity.Property(e => e.ApiKey).IsRequired().HasMaxLength(2000);
entity.Property(e => e.RegisteredAt).IsRequired();
});
}
}
@@ -0,0 +1,12 @@
namespace SlpModularCms.Modules.Availability.Data.Entities;
public class MasterRegistration
{
public static readonly Guid SingletonId = new("00000000-0000-0000-0000-000000000001");
public Guid Id { get; set; }
public string MasterUrl { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public DateTimeOffset RegisteredAt { get; set; }
public DateTimeOffset? LastContactedAt { get; set; }
}
@@ -4,13 +4,10 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SlpModularCms.Core.Availability;
using SlpModularCms.Core.Exceptions;
using SlpModularCms.Modules.Availability.Services;
namespace SlpModularCms.Modules.Availability.Middleware;
/// <summary>
/// Middleware die de beschikbaarheid van het systeem controleert en requests blokkeert indien nodig.
/// </summary>
public class AvailabilityMiddleware
{
private readonly RequestDelegate _next;
@@ -25,14 +22,19 @@ public class AvailabilityMiddleware
// Paths that are always accessible regardless of system availability.
// Auth and Setup must stay open so admins can log in and the frontend
// can determine whether the system is initialized.
// Master endpoints bypass so master can always push status or re-register.
private static readonly string[] _bypassPrefixes =
[
"/api/v1/Availability/status",
"/api/v1/Auth/",
"/api/v1/Setup/status",
"/api/v1/master/",
];
public async Task InvokeAsync(HttpContext context, IAvailabilityService availabilityService)
public async Task InvokeAsync(
HttpContext context,
IAvailabilityService availabilityService,
IMasterAvailabilityService masterAvailabilityService)
{
var path = context.Request.Path.Value ?? string.Empty;
if (_bypassPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase)))
@@ -41,35 +43,47 @@ public class AvailabilityMiddleware
return;
}
var status = await availabilityService.IsAvailableAsync();
if (status == AvailabilityStatus.Available)
{
await _next(context);
return;
}
// Check voor Beheerder Bypass (Owner of Administrator)
if (IsAdminBypass(context))
{
await _next(context);
return;
}
_logger.LogWarning("Request geblokkeerd vanwege systeemstatus: {Status}. Path: {Path}", status, context.Request.Path);
var masterStatus = masterAvailabilityService.GetMasterStatus();
if (!masterStatus.IsAvailable)
{
_logger.LogWarning(
"Request blocked by master gate. Path: {Path}, DisableMessage: {DisableMessage}",
path, masterStatus.DisableMessage);
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status503ServiceUnavailable,
Title = "Service Unavailable",
Detail = masterStatus.DisableMessage ?? "De service is tijdelijk niet beschikbaar.",
Instance = context.Request.Path
});
return;
}
var status = await availabilityService.IsAvailableAsync();
if (status == AvailabilityStatus.Available)
{
await _next(context);
return;
}
_logger.LogWarning("Request geblokkeerd vanwege systeemstatus: {Status}. Path: {Path}", status, context.Request.Path);
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
var problemDetails = new ProblemDetails
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status503ServiceUnavailable,
Title = "Service Unavailable",
Detail = status == AvailabilityStatus.Maintenance
? "Het systeem is momenteel in onderhoud. Probeer het later opnieuw."
Detail = status == AvailabilityStatus.Maintenance
? "Het systeem is momenteel in onderhoud. Probeer het later opnieuw."
: "De service is tijdelijk niet beschikbaar.",
Instance = context.Request.Path
};
await context.Response.WriteAsJsonAsync(problemDetails);
});
}
private bool IsAdminBypass(HttpContext context)
@@ -91,7 +105,6 @@ public class AvailabilityMiddleware
}
catch (Exception)
{
// Ongeldig token of parsing fout: geen bypass
return false;
}
}
@@ -0,0 +1,9 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Modules.Availability.Models;
[ExcludeFromCodeCoverage]
public record RegisterMasterRequest(string MasterUrl);
[ExcludeFromCodeCoverage]
public record PushStatusRequest(bool IsAvailable, string? DisableMessage);
@@ -0,0 +1,11 @@
using SlpModularCms.Modules.Availability.Data.Entities;
namespace SlpModularCms.Modules.Availability.Repositories;
public interface IMasterRegistrationRepository
{
Task<MasterRegistration?> GetAsync();
Task AddAsync(MasterRegistration registration);
void Update(MasterRegistration registration);
Task SaveChangesAsync();
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using SlpModularCms.Modules.Availability.Data;
using SlpModularCms.Modules.Availability.Data.Entities;
namespace SlpModularCms.Modules.Availability.Repositories;
public class MasterRegistrationRepository(AvailabilityDbContext context) : IMasterRegistrationRepository
{
public Task<MasterRegistration?> GetAsync()
=> context.MasterRegistrations.FirstOrDefaultAsync(r => r.Id == MasterRegistration.SingletonId);
public async Task AddAsync(MasterRegistration registration)
=> await context.MasterRegistrations.AddAsync(registration);
public void Update(MasterRegistration registration)
=> context.MasterRegistrations.Update(registration);
public Task SaveChangesAsync()
=> context.SaveChangesAsync();
}
@@ -0,0 +1,7 @@
namespace SlpModularCms.Modules.Availability.Services;
public interface IMasterApiKeyProtector
{
string Protect(string plainApiKey);
string? Unprotect(string encryptedApiKey);
}
@@ -0,0 +1,9 @@
namespace SlpModularCms.Modules.Availability.Services;
public interface IMasterAvailabilityService
{
Task<bool> RegisterAsync(string masterUrl, string apiKey);
Task<bool> PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
Task<string?> GetRegisteredUrlAsync(string apiKey);
MasterGateStatus GetMasterStatus();
}
@@ -0,0 +1,21 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;
namespace SlpModularCms.Modules.Availability.Services;
public class MasterApiKeyProtector : IMasterApiKeyProtector
{
private readonly IDataProtector _protector;
public MasterApiKeyProtector(IDataProtectionProvider provider)
=> _protector = provider.CreateProtector("SlpModularCms.Availability.MasterApiKey");
public string Protect(string plainApiKey)
=> _protector.Protect(plainApiKey);
public string? Unprotect(string encryptedApiKey)
{
try { return _protector.Unprotect(encryptedApiKey); }
catch (CryptographicException) { return null; }
}
}
@@ -0,0 +1,103 @@
using Microsoft.Extensions.Logging;
using SlpModularCms.Modules.Availability.Data.Entities;
namespace SlpModularCms.Modules.Availability.Services;
public class MasterAvailabilityService : IMasterAvailabilityService
{
private readonly MasterAvailabilityServiceDependencies _deps;
private static volatile bool _masterIsAvailable = true;
private static volatile string? _masterDisableMessage;
public MasterAvailabilityService(MasterAvailabilityServiceDependencies deps)
=> _deps = deps;
public MasterGateStatus GetMasterStatus()
=> new(_masterIsAvailable, _masterDisableMessage);
public async Task<bool> RegisterAsync(string masterUrl, string apiKey)
{
var existing = await _deps.Repository.GetAsync();
if (existing is null)
{
var protectedKey = _deps.KeyProtector.Protect(apiKey);
var reg = new MasterRegistration
{
Id = MasterRegistration.SingletonId,
MasterUrl = masterUrl,
ApiKey = protectedKey,
RegisteredAt = DateTimeOffset.UtcNow,
LastContactedAt = DateTimeOffset.UtcNow
};
await _deps.Repository.AddAsync(reg);
await _deps.Repository.SaveChangesAsync();
_deps.Logger.LogInformation("Master registered for the first time at {MasterUrl}", masterUrl);
return true;
}
if (!IsKeyValid(existing, apiKey))
{
_deps.Logger.LogWarning("Master API key mismatch on {Endpoint}", "POST /api/v1/master/register");
return false;
}
existing.MasterUrl = masterUrl;
existing.LastContactedAt = DateTimeOffset.UtcNow;
_deps.Repository.Update(existing);
await _deps.Repository.SaveChangesAsync();
_deps.Logger.LogInformation("Master re-registered at {MasterUrl}", masterUrl);
return true;
}
public async Task<bool> PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage)
{
var existing = await _deps.Repository.GetAsync();
if (!IsKeyValid(existing, apiKey))
{
_deps.Logger.LogWarning("Master API key mismatch on {Endpoint}", "POST /api/v1/master/status");
return false;
}
_masterIsAvailable = isAvailable;
_masterDisableMessage = disableMessage;
existing!.LastContactedAt = DateTimeOffset.UtcNow;
_deps.Repository.Update(existing);
await _deps.Repository.SaveChangesAsync();
_deps.Logger.LogInformation(
"Master status update received: IsAvailable={IsAvailable}, DisableMessage={DisableMessage}",
isAvailable, disableMessage);
return true;
}
public async Task<string?> GetRegisteredUrlAsync(string apiKey)
{
var existing = await _deps.Repository.GetAsync();
if (!IsKeyValid(existing, apiKey))
{
_deps.Logger.LogWarning("Master API key mismatch on {Endpoint}", "GET /api/v1/master/registered-url");
return null;
}
existing!.LastContactedAt = DateTimeOffset.UtcNow;
_deps.Repository.Update(existing);
await _deps.Repository.SaveChangesAsync();
_deps.Logger.LogDebug("Get-registered-url called");
return existing.MasterUrl;
}
private bool IsKeyValid(MasterRegistration? registration, string apiKey)
{
if (registration is null) return false;
var stored = _deps.KeyProtector.Unprotect(registration.ApiKey);
return stored is not null && stored == apiKey;
}
internal static void ResetStaticCacheForTest()
{
_masterIsAvailable = true;
_masterDisableMessage = null;
}
}
@@ -0,0 +1,12 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.Logging;
using SlpModularCms.Modules.Availability.Repositories;
namespace SlpModularCms.Modules.Availability.Services;
[ExcludeFromCodeCoverage]
public record MasterAvailabilityServiceDependencies(
IMasterRegistrationRepository Repository,
IMasterApiKeyProtector KeyProtector,
ILogger<MasterAvailabilityService> Logger
);
@@ -0,0 +1,6 @@
using System.Diagnostics.CodeAnalysis;
namespace SlpModularCms.Modules.Availability.Services;
[ExcludeFromCodeCoverage]
public record MasterGateStatus(bool IsAvailable, string? DisableMessage);
@@ -1,13 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>SlpModularCms.Modules.Availability.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
</ItemGroup>
</Project>