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
@@ -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);