(feature): cache handling utilities

This commit is contained in:
Maksym Sadovnychyy 2025-11-07 22:53:10 +01:00
parent 7d60b77c62
commit 1e2d4156a5
27 changed files with 683 additions and 201 deletions

View File

@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace MaksIT.LetsEncrypt.Entities;
public enum ChalengeType {
[Display(Name = "http-01")]
http,
[Display(Name = "dns-01")]
dns,
}

View File

@ -1,27 +1,16 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace MaksIT.LetsEncrypt.Entities
{
public enum ContentType
{
[Display(Name = "application/jose+json")]
JoseJson,
[Display(Name = "application/problem+json")]
ProblemJson,
[Display(Name = "application/pem-certificate-chain")]
PemCertificateChain,
[Display(Name = "application/json")]
Json
}
public static class ContentTypeExtensions namespace MaksIT.LetsEncrypt.Entities;
{
public static string GetDisplayName(this ContentType contentType) public enum ContentType
{ {
var type = typeof(ContentType); [Display(Name = "application/jose+json")]
var memInfo = type.GetMember(contentType.ToString()); JoseJson,
var attributes = memInfo[0].GetCustomAttributes(typeof(DisplayAttribute), false); [Display(Name = "application/problem+json")]
return attributes.Length > 0 ? ((DisplayAttribute)attributes[0]).Name : contentType.ToString(); ProblemJson,
} [Display(Name = "application/pem-certificate-chain")]
} PemCertificateChain,
} [Display(Name = "application/json")]
Json
}

View File

@ -18,7 +18,7 @@ public class RegistrationCache {
public required string Description { get; set; } public required string Description { get; set; }
public required string[] Contacts { get; set; } public required string[] Contacts { get; set; }
public required bool IsStaging { get; set; } public required bool IsStaging { get; set; }
public string? ChallengeType { get; set; } public required string ChallengeType { get; set; }
#endregion #endregion

View File

@ -1,27 +1,16 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace MaksIT.LetsEncrypt.Entities
{
public enum OrderStatus
{
[Display(Name = "pending")]
Pending,
[Display(Name = "valid")]
Valid,
[Display(Name = "ready")]
Ready,
[Display(Name = "processing")]
Processing
}
public static class OrderStatusExtensions namespace MaksIT.LetsEncrypt.Entities;
{
public static string GetDisplayName(this OrderStatus status) public enum OrderStatus
{ {
var type = typeof(OrderStatus); [Display(Name = "pending")]
var memInfo = type.GetMember(status.ToString()); Pending,
var attributes = memInfo[0].GetCustomAttributes(typeof(DisplayAttribute), false); [Display(Name = "valid")]
return attributes.Length > 0 ? ((DisplayAttribute)attributes[0]).Name : status.ToString(); Valid,
} [Display(Name = "ready")]
} Ready,
} [Display(Name = "processing")]
Processing
}

View File

@ -8,7 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.5.1" /> <PackageReference Include="MaksIT.Core" Version="1.5.2" />
<PackageReference Include="MaksIT.Results" Version="1.1.1" /> <PackageReference Include="MaksIT.Results" Version="1.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />

View File

@ -22,6 +22,7 @@ using System.Text;
namespace MaksIT.LetsEncrypt.Services; namespace MaksIT.LetsEncrypt.Services;
public interface ILetsEncryptService { public interface ILetsEncryptService {
Task<Result> ConfigureClient(Guid sessionId, bool isStaging); Task<Result> ConfigureClient(Guid sessionId, bool isStaging);
Task<Result> Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache); Task<Result> Init(Guid sessionId,Guid accountId, string description, string[] contacts, RegistrationCache? registrationCache);
@ -216,6 +217,7 @@ public class LetsEncryptService : ILetsEncryptService {
Description = description, Description = description,
Contacts = contacts, Contacts = contacts,
IsStaging = state.IsStaging, IsStaging = state.IsStaging,
ChallengeType = ChalengeType.http.GetDisplayName(),
Location = result.Result.Location, Location = result.Result.Location,
AccountKey = accountKey.ExportCspBlob(true), AccountKey = accountKey.ExportCspBlob(true),
Id = result.Result.Id ?? string.Empty, Id = result.Result.Id ?? string.Empty,

View File

@ -0,0 +1,23 @@
using MaksIT.Results;
namespace LetsEncryptServer.Abstractions;
public abstract class ServiceBase {
protected Result UnsupportedPatchOperationResponse() {
return Result.BadRequest("Unsupported operation");
}
protected Result<T?> UnsupportedPatchOperationResponse<T>() {
return Result<T?>.BadRequest(default, "Unsupported operation");
}
protected Result PatchFieldIsNotDefined(string fieldName) {
return Result.BadRequest($"It's not possible to set non defined field {fieldName}.");
}
protected Result<T?> PatchFieldIsNotDefined<T>(string fieldName) {
return Result<T?>.BadRequest(default, $"It's not possible to set non defined field {fieldName}.");
}
}

View File

@ -8,8 +8,8 @@ namespace LetsEncryptServer.Controllers;
public class CacheController(ICacheService cacheService) : ControllerBase { public class CacheController(ICacheService cacheService) : ControllerBase {
private readonly ICacheService _cacheService = cacheService; private readonly ICacheService _cacheService = cacheService;
[HttpGet("caches/download")] [HttpGet("cache/download")]
public async Task<IActionResult> GetCaches() { public async Task<IActionResult> GetCache() {
var result = await _cacheService.DownloadCacheZipAsync(); var result = await _cacheService.DownloadCacheZipAsync();
if (!result.IsSuccess || result.Value == null) { if (!result.IsSuccess || result.Value == null) {
return result.ToActionResult(); return result.ToActionResult();
@ -17,12 +17,23 @@ public class CacheController(ICacheService cacheService) : ControllerBase {
var bytes = result.Value; var bytes = result.Value;
return File(bytes, "application/zip", "caches.zip"); return File(bytes, "application/zip", "cache.zip");
} }
[HttpPost("caches/upload")] [HttpPost("cache/upload")]
public async Task<IActionResult> PostCaches([FromBody] byte[] zipBytes) { //[RequestSizeLimit(200_000_000)]
var result = await _cacheService.UploadCacheZipAsync(zipBytes); public async Task<IActionResult> PostCache([FromForm] IFormFile file) {
if (file is null || file.Length == 0) return BadRequest("No file.");
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
var result = await _cacheService.UploadCacheZipAsync(ms.ToArray());
return result.ToActionResult();
}
[HttpDelete("cache")]
public IActionResult DeleteCache() {
var result = _cacheService.DeleteCacheAsync();
return result.ToActionResult(); return result.ToActionResult();
} }
@ -43,4 +54,6 @@ public class CacheController(ICacheService cacheService) : ControllerBase {
var result = await _cacheService.UploadAccountCacheZipAsync(accountId, zipBytes); var result = await _cacheService.UploadAccountCacheZipAsync(accountId, zipBytes);
return result.ToActionResult(); return result.ToActionResult();
} }
} }

View File

@ -1,16 +1,19 @@
using MaksIT.Core.Webapi.Middlewares;
using MaksIT.Core.Logging; using MaksIT.Core.Logging;
using MaksIT.Core.Webapi.Middlewares;
using MaksIT.LetsEncrypt.Extensions; using MaksIT.LetsEncrypt.Extensions;
using MaksIT.LetsEncrypt.Services;
using MaksIT.LetsEncryptServer; using MaksIT.LetsEncryptServer;
using MaksIT.LetsEncryptServer.BackgroundServices; using MaksIT.LetsEncryptServer.BackgroundServices;
using MaksIT.LetsEncryptServer.Services; using MaksIT.LetsEncryptServer.Services;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Extract configuration // Extract configuration
var configuration = builder.Configuration; var configuration = builder.Configuration;
// Add logging
builder.Logging.AddConsoleLogger();
var configMapPath = Path.Combine(Path.DirectorySeparatorChar.ToString(), "configMap", "appsettings.json"); var configMapPath = Path.Combine(Path.DirectorySeparatorChar.ToString(), "configMap", "appsettings.json");
if (File.Exists(configMapPath)) { if (File.Exists(configMapPath)) {
configuration.AddJsonFile(configMapPath, optional: false, reloadOnChange: true); configuration.AddJsonFile(configMapPath, optional: false, reloadOnChange: true);
@ -25,16 +28,16 @@ if (File.Exists(secretsPath)) {
var configurationSection = configuration.GetSection("Configuration"); var configurationSection = configuration.GetSection("Configuration");
var appSettings = configurationSection.Get<Configuration>() ?? throw new ArgumentNullException(); var appSettings = configurationSection.Get<Configuration>() ?? throw new ArgumentNullException();
// Add logging
builder.Logging.AddConsoleLogger();
// Allow configurations to be available through IOptions<Configuration> // Allow configurations to be available through IOptions<Configuration>
builder.Services.Configure<Configuration>(configurationSection); builder.Services.Configure<Configuration>(configurationSection);
// Add services to the container. // Add services to the container.
builder.Services.AddControllers()
.AddJsonOptions(options => {
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();

View File

@ -1,9 +1,13 @@
 
using LetsEncryptServer.Abstractions;
using MaksIT.Core.Webapi.Models;
using MaksIT.LetsEncrypt.Entities; using MaksIT.LetsEncrypt.Entities;
using MaksIT.Models; using MaksIT.Models;
using MaksIT.Models.LetsEncryptServer.Account.Requests; using MaksIT.Models.LetsEncryptServer.Account.Requests;
using MaksIT.Models.LetsEncryptServer.Account.Responses; using MaksIT.Models.LetsEncryptServer.Account.Responses;
using MaksIT.Results; using MaksIT.Results;
using System;
using static System.Collections.Specialized.BitVector32;
namespace MaksIT.LetsEncryptServer.Services; namespace MaksIT.LetsEncryptServer.Services;
@ -23,7 +27,7 @@ public interface IAccountRestService {
public interface IAccountService : IAccountInternalService, IAccountRestService { } public interface IAccountService : IAccountInternalService, IAccountRestService { }
public class AccountService : IAccountService { public class AccountService : ServiceBase, IAccountService {
private readonly ILogger<CacheService> _logger; private readonly ILogger<CacheService> _logger;
private readonly ICacheService _cacheService; private readonly ICacheService _cacheService;
@ -69,8 +73,6 @@ public class AccountService : IAccountService {
public async Task<Result<GetAccountResponse?>> PostAccountAsync(PostAccountRequest requestData) { public async Task<Result<GetAccountResponse?>> PostAccountAsync(PostAccountRequest requestData) {
// TODO: check for overlapping hostnames in already existing accounts
var fullFlowResult = await _certsFlowService.FullFlow( var fullFlowResult = await _certsFlowService.FullFlow(
requestData.IsStaging, requestData.IsStaging,
null, null,
@ -80,19 +82,17 @@ public class AccountService : IAccountService {
requestData.Hostnames requestData.Hostnames
); );
if (!fullFlowResult.IsSuccess || fullFlowResult.Value == null) if (!fullFlowResult.IsSuccess || fullFlowResult.Value == null)
return fullFlowResult.ToResultOfType<GetAccountResponse?>(_ => null); return fullFlowResult.ToResultOfType<GetAccountResponse?>(_ => null);
var accountId = fullFlowResult.Value.Value; var accountId = fullFlowResult.Value.Value;
var loadAccauntFromCacheResult = await _cacheService.LoadAccountFromCacheAsync(accountId); var loadAccountFromCacheResult = await _cacheService.LoadAccountFromCacheAsync(accountId);
if (!loadAccauntFromCacheResult.IsSuccess || loadAccauntFromCacheResult.Value == null) { if (!loadAccountFromCacheResult.IsSuccess || loadAccountFromCacheResult.Value == null) {
return loadAccauntFromCacheResult.ToResultOfType<GetAccountResponse?>(_ => null); return loadAccountFromCacheResult.ToResultOfType<GetAccountResponse?>(_ => null);
} }
var cache = loadAccauntFromCacheResult.Value; var cache = loadAccountFromCacheResult.Value;
return Result<GetAccountResponse?>.Ok(CreateGetAccountResponse(accountId, cache)); return Result<GetAccountResponse?>.Ok(CreateGetAccountResponse(accountId, cache));
} }
@ -105,76 +105,60 @@ public class AccountService : IAccountService {
var cache = loadAccountResult.Value; var cache = loadAccountResult.Value;
if (requestData.Description != null) { if (requestData.TryGetOperation(nameof(requestData.Description), out var patchOperation)) {
switch (requestData.Description.Op) { switch (patchOperation) {
case PatchOperation.Replace: case PatchOperation.SetField:
cache.Description = requestData.Description.Value; if (requestData.Description == null)
return PatchFieldIsNotDefined<GetAccountResponse?>(nameof(requestData.Description));
cache.Description = requestData.Description;
break; break;
default:
return UnsupportedPatchOperationResponse<GetAccountResponse?>();
} }
} }
if (requestData.IsDisabled != null) { if (requestData.TryGetOperation(nameof(requestData.IsDisabled), out patchOperation)) {
switch (requestData.IsDisabled.Op) { switch (patchOperation) {
case PatchOperation.Replace: case PatchOperation.SetField:
if (requestData.IsDisabled == null)
return PatchFieldIsNotDefined<GetAccountResponse?>(nameof(requestData.IsDisabled));
cache.IsDisabled = requestData.IsDisabled.Value; cache.IsDisabled = requestData.IsDisabled.Value;
break; break;
default:
return UnsupportedPatchOperationResponse<GetAccountResponse?>();
} }
} }
if (requestData.Contacts?.Any() == true) { if (requestData.TryGetOperation(nameof(requestData.Contacts), out patchOperation)) {
var contacts = cache.Contacts?.ToList() ?? new List<string>(); switch (patchOperation) {
foreach (var action in requestData.Contacts) { case PatchOperation.SetField:
switch (action.Op) if (requestData.Contacts == null)
{ return PatchFieldIsNotDefined<GetAccountResponse?>(nameof(requestData.Contacts));
case PatchOperation.Add: cache.Contacts = requestData.Contacts.ToArray();
if (action.Value != null) contacts.Add(action.Value); break;
break;
case PatchOperation.Replace:
if (action.Index != null && action.Index >= 0 && action.Index < contacts.Count)
contacts[action.Index.Value] = action.Value;
break;
case PatchOperation.Remove:
if (action.Index != null && action.Index >= 0 && action.Index < contacts.Count)
contacts.RemoveAt(action.Index.Value);
break;
}
} }
cache.Contacts = contacts.ToArray();
} }
#region Patch Hostnames
var hostnamesToAdd = new List<string>(); var hostnamesToAdd = new List<string>();
var hostnamesToRemove = new List<string>(); var hostnamesToRemove = new List<string>();
if (requestData.Hostnames?.Any() == true) { foreach (var hostnameRequestData in requestData.Hostnames ?? []) {
var hostnames = cache.GetHosts().ToList(); if (hostnameRequestData.TryGetOperation("collectionItemOperation", out patchOperation)) {
foreach (var action in requestData.Hostnames) {
if (action.Hostname != null) { if (hostnameRequestData.Hostname == null)
switch (action.Hostname.Op) { return PatchFieldIsNotDefined<GetAccountResponse?>(nameof(hostnameRequestData.Hostname));
case PatchOperation.Add:
hostnamesToAdd.Add(action.Hostname.Value);
break; switch (patchOperation) {
case PatchOperation.AddToCollection:
hostnamesToAdd.Add(hostnameRequestData.Hostname);
break;
case PatchOperation.Replace: case PatchOperation.RemoveFromCollection:
if (action.Hostname.Index != null && action.Hostname.Index >= 0 && action.Hostname.Index < hostnames.Count) hostnamesToRemove.Add(hostnameRequestData.Hostname);
hostnames[action.Hostname.Index.Value].Hostname = action.Hostname.Value; break;
break;
case PatchOperation.Remove:
hostnamesToRemove.Add(action.Hostname.Value);
break;
}
}
if (action.IsDisabled != null) {
switch (action.IsDisabled.Op) {
case PatchOperation.Replace:
break;
}
} }
} }
} }
@ -210,6 +194,7 @@ public class AccountService : IAccountService {
if (!revokeResult.IsSuccess) if (!revokeResult.IsSuccess)
return revokeResult.ToResultOfType<GetAccountResponse?>(default); return revokeResult.ToResultOfType<GetAccountResponse?>(default);
} }
#endregion
loadAccountResult = await _cacheService.LoadAccountFromCacheAsync(accountId); loadAccountResult = await _cacheService.LoadAccountFromCacheAsync(accountId);
if (!loadAccountResult.IsSuccess || loadAccountResult.Value == null) { if (!loadAccountResult.IsSuccess || loadAccountResult.Value == null) {

View File

@ -19,7 +19,7 @@ public interface ICacheService {
Task<Result<byte[]?>> DownloadAccountCacheZipAsync(Guid accountId); Task<Result<byte[]?>> DownloadAccountCacheZipAsync(Guid accountId);
Task<Result> UploadCacheZipAsync(byte[] zipBytes); Task<Result> UploadCacheZipAsync(byte[] zipBytes);
Task<Result> UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes); Task<Result> UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes);
Task<Result> ClearCacheAsync(); Result DeleteCacheAsync();
} }
public class CacheService : ICacheService, IDisposable { public class CacheService : ICacheService, IDisposable {
@ -222,11 +222,18 @@ public class CacheService : ICacheService, IDisposable {
} }
} }
public async Task<Result> ClearCacheAsync() { public Result DeleteCacheAsync() {
try { try {
if (Directory.Exists(_cacheDirectory)) { if (Directory.Exists(_cacheDirectory)) {
Directory.Delete(_cacheDirectory, true); // Delete all files
_logger.LogInformation("Cache directory cleared."); foreach (var file in Directory.GetFiles(_cacheDirectory)) {
File.Delete(file);
}
// Delete all subdirectories
foreach (var dir in Directory.GetDirectories(_cacheDirectory)) {
Directory.Delete(dir, true);
}
_logger.LogInformation("Cache directory contents cleared.");
} }
else { else {
_logger.LogWarning("Cache directory not found to clear."); _logger.LogWarning("Cache directory not found to clear.");
@ -234,7 +241,7 @@ public class CacheService : ICacheService, IDisposable {
return Result.Ok(); return Result.Ok();
} }
catch (Exception ex) { catch (Exception ex) {
var message = "Error clearing cache directory."; var message = "Error clearing cache directory contents.";
_logger.LogError(ex, message); _logger.LogError(ex, message);
return Result.InternalServerError([message, .. ex.ExtractMessages()]); return Result.InternalServerError([message, .. ex.ExtractMessages()]);
} }

View File

@ -1,3 +1,4 @@
VITE_APP_TITLE=MaksIT.CertsUI VITE_APP_TITLE=MaksIT.CertsUI
VITE_COMPANY=MaksIT VITE_COMPANY=MaksIT
VITE_COMPANY_URL=https://maks-it.com
VITE_API_URL=http://localhost:8080/api VITE_API_URL=http://localhost:8080/api

View File

@ -11,6 +11,7 @@
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.11.0", "axios": "^1.11.0",
"client-zip": "^2.5.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@ -2632,6 +2633,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/client-zip": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/client-zip/-/client-zip-2.5.0.tgz",
"integrity": "sha512-ydG4nDZesbFurnNq0VVCp/yyomIBh+X/1fZPI/P24zbnG4dtC4tQAfI5uQsomigsUMeiRO2wiTPizLWQh+IAyQ==",
"license": "MIT"
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",

View File

@ -13,6 +13,7 @@
"@reduxjs/toolkit": "^2.8.2", "@reduxjs/toolkit": "^2.8.2",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"axios": "^1.11.0", "axios": "^1.11.0",
"client-zip": "^2.5.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

@ -45,7 +45,7 @@ const LayoutWrapper: FC<LayoutWrapperProps> = (props) => {
} }
footer={ footer={
{ {
children: <p>&copy; {new Date().getFullYear()} {import.meta.env.VITE_COMPANY}</p> children: <p>&copy; {new Date().getFullYear()} <a href={import.meta.env.VITE_COMPANY_URL}>{import.meta.env.VITE_COMPANY}</a></p>
} }
} }
>{children}</Layout> >{children}</Layout>
@ -147,7 +147,9 @@ const AppMap: AppMapType[] = [
enum ApiRoutes { enum ApiRoutes {
ACCOUNTS = 'GET|/accounts',
// Accounts
ACCOUNTS_GET = 'GET|/accounts',
ACCOUNT_POST = 'POST|/account', ACCOUNT_POST = 'POST|/account',
ACCOUNT_GET = 'GET|/account/{accountId}', ACCOUNT_GET = 'GET|/account/{accountId}',
@ -160,14 +162,23 @@ enum ApiRoutes {
// ACCOUNT_ID_HOSTNAMES = 'GET|/account/{accountId}/hostnames', // ACCOUNT_ID_HOSTNAMES = 'GET|/account/{accountId}/hostnames',
// ACCOUNT_ID_HOSTNAME_ID = 'GET|/account/{accountId}/hostname/{index}', // ACCOUNT_ID_HOSTNAME_ID = 'GET|/account/{accountId}/hostname/{index}',
// Agents
AGENT_TEST = 'GET|/agent/test',
// Certs flow // Certs flow
CERTS_FLOW_CONFIGURE_CLIENT = 'POST|/certs/configure-client', CERTS_FLOW_CONFIGURE_CLIENT = 'POST|/certs/configure-client',
CERTS_FLOW_TERMS_OF_SERVICE = 'GET|/certs/{sessionId}/terms-of-service', CERTS_FLOW_TERMS_OF_SERVICE = 'GET|/certs/{sessionId}/terms-of-service',
CERTS_FLOW_CERTIFICATES_APPLY = 'POST|/certs/{accountId}/certificates/apply', CERTS_FLOW_CERTIFICATES_APPLY = 'POST|/certs/{accountId}/certificates/apply',
// Caches
FULL_CACHE_DOWNLOAD_GET = 'GET|/cache/download',
FULL_CACHE_UPLOAD_POST = 'POST|/cache/upload',
FULL_CACHE_DELETE = 'DELETE|/cache',
CACHE_DOWNLOAD_GET = 'GET|/cache/{accountId}/download/',
CACHE_UPLOAD_POST = 'POST|/cache/{accountId}/upload/',
// Agents
AGENT_TEST = 'GET|/agent/test',
// Secrets // Secrets
generateSecret = 'GET|/secret/generatesecret', generateSecret = 'GET|/secret/generatesecret',

View File

@ -5,10 +5,8 @@ import { store } from './redux/store'
import { refreshJwt } from './redux/slices/identitySlice' import { refreshJwt } from './redux/slices/identitySlice'
import { hideLoader, showLoader } from './redux/slices/loaderSlice' import { hideLoader, showLoader } from './redux/slices/loaderSlice'
import { addToast } from './components/Toast/addToast' import { addToast } from './components/Toast/addToast'
import { de } from 'zod/v4/locales' import { ProblemDetails } from './models/ProblemDetails'
import { deepPatternMatch } from './functions'
import { ProblemDetails, ProblemDetailsProto } from './models/ProblemDetails'
import { add } from 'lodash'
// Create an Axios instance // Create an Axios instance
const axiosInstance = axios.create({ const axiosInstance = axios.create({
@ -99,6 +97,7 @@ axiosInstance.interceptors.response.use(
* Performs a GET request and returns the response data. * Performs a GET request and returns the response data.
* @param url The endpoint URL. * @param url The endpoint URL.
* @param timeout Optional timeout in milliseconds to override the default. * @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/ */
const getData = async <TResponse>(url: string, timeout?: number): Promise<TResponse | undefined> => { const getData = async <TResponse>(url: string, timeout?: number): Promise<TResponse | undefined> => {
try { try {
@ -120,6 +119,7 @@ const getData = async <TResponse>(url: string, timeout?: number): Promise<TRespo
* @param url The endpoint URL. * @param url The endpoint URL.
* @param data The request payload. * @param data The request payload.
* @param timeout Optional timeout in milliseconds to override the default. * @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/ */
const postData = async <TRequest, TResponse>(url: string, data?: TRequest, timeout?: number): Promise<TResponse | undefined> => { const postData = async <TRequest, TResponse>(url: string, data?: TRequest, timeout?: number): Promise<TResponse | undefined> => {
try { try {
@ -142,6 +142,7 @@ const postData = async <TRequest, TResponse>(url: string, data?: TRequest, timeo
* @param url The endpoint URL. * @param url The endpoint URL.
* @param data The request payload. * @param data The request payload.
* @param timeout Optional timeout in milliseconds to override the default. * @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/ */
const patchData = async <TRequest, TResponse>(url: string, data: TRequest, timeout?: number): Promise<TResponse | undefined> => { const patchData = async <TRequest, TResponse>(url: string, data: TRequest, timeout?: number): Promise<TResponse | undefined> => {
try { try {
@ -163,6 +164,7 @@ const patchData = async <TRequest, TResponse>(url: string, data: TRequest, timeo
* @param url The endpoint URL. * @param url The endpoint URL.
* @param data The request payload. * @param data The request payload.
* @param timeout Optional timeout in milliseconds to override the default. * @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/ */
const putData = async <TRequest, TResponse>(url: string, data: TRequest, timeout?: number): Promise<TResponse | undefined> => { const putData = async <TRequest, TResponse>(url: string, data: TRequest, timeout?: number): Promise<TResponse | undefined> => {
try { try {
@ -183,6 +185,7 @@ const putData = async <TRequest, TResponse>(url: string, data: TRequest, timeout
* Performs a DELETE request and returns the response data. * Performs a DELETE request and returns the response data.
* @param url The endpoint URL. * @param url The endpoint URL.
* @param timeout Optional timeout in milliseconds to override the default. * @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/ */
const deleteData = async <TResponse>(url: string, timeout?: number): Promise<TResponse | undefined> => { const deleteData = async <TResponse>(url: string, timeout?: number): Promise<TResponse | undefined> => {
try { try {
@ -199,11 +202,141 @@ const deleteData = async <TResponse>(url: string, timeout?: number): Promise<TRe
} }
} }
/**
* Performs a POST request with binary payload (e.g., file upload) and returns the response data.
* @param url The endpoint URL.
* @param data The binary request payload.
* @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/
const postBinary = async <TResponse>(
url: string,
data: Blob | ArrayBuffer | Uint8Array,
timeout?: number
): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.post<TResponse>(url, data, {
headers: {
'Content-Type': 'application/octet-stream'
},
...(timeout ? { timeout } : {})
})
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
}
}
/**
* Performs a GET request to retrieve binary data (e.g., file download).
* @param url The endpoint URL.
* @param timeout Optional timeout in milliseconds to override the default.
* @param as The format to retrieve the binary data as ('arraybuffer' or 'blob').
* @returns The binary data and headers, or undefined if an error occurs.
*/
const getBinary = async (
url: string,
timeout?: number,
as: 'arraybuffer' | 'blob' = 'arraybuffer'
): Promise<{ data: ArrayBuffer | Blob, headers: Record<string, string> } | undefined> => {
try {
const response = await axiosInstance.get(url, {
responseType: as,
...(timeout ? { timeout } : {})
})
return {
data: response.data,
headers: response.headers as Record<string, string>
}
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
}
}
/**
* Performs a POST request using multipart/form-data.
* Accepts either a ready FormData or a record of fields to be converted into FormData.
* Note: Do NOT set the Content-Type header manually; the browser will include the boundary.
* @param url The endpoint URL.
* @param form The FormData instance or a record of fields.
* Values can be string | Blob | File | (string | Blob | File)[]
* @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/
const postFormData = async <TResponse>(
url: string,
form: FormData | Record<string, string | Blob | File | (string | Blob | File)[]>,
timeout?: number
): Promise<TResponse | undefined> => {
try {
const formData =
form instanceof FormData
? form
: (() => {
const fd = new FormData()
Object.entries(form).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => fd.append(key, v))
} else {
fd.append(key, value)
}
})
return fd
})()
const response = await axiosInstance.post<TResponse>(url, formData, {
// Do NOT set Content-Type; the browser will set the correct multipart boundary
...(timeout ? { timeout } : {})
})
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
}
}
/**
* Convenience helper for uploading a single file via multipart/form-data.
* @param url The endpoint URL.
* @param file The file/blob to upload.
* @param fieldName The form field name for the file (default: "file").
* @param filename Optional filename; if omitted and "file" is a File, the File.name is used.
* @param extraFields Optional extra key/value fields to include in the form.
* @param timeout Optional timeout in milliseconds to override the default.
* @returns The response data, or undefined if an error occurs.
*/
const postFile = async <TResponse>(
url: string,
file: Blob | File,
fieldName: string = 'file',
filename?: string,
extraFields?: Record<string, string>,
timeout?: number
): Promise<TResponse | undefined> => {
const fd = new FormData()
const inferredName = filename ?? (file instanceof File ? file.name : 'file')
fd.append(fieldName, file, inferredName)
if (extraFields) {
Object.entries(extraFields).forEach(([k, v]) => fd.append(k, v))
}
return postFormData<TResponse>(url, fd, timeout)
}
export { export {
axiosInstance, axiosInstance,
getData, getData,
postData, postData,
patchData, patchData,
putData, putData,
deleteData deleteData,
postBinary,
getBinary,
postFormData,
postFile
} }

View File

@ -24,8 +24,8 @@ const EditAccountHostnameFormProto = (): EditAccountHostnameFormProps => ({
}) })
const EditAccountHostnameFormSchema: Schema<EditAccountHostnameFormProps> = object({ const EditAccountHostnameFormSchema: Schema<EditAccountHostnameFormProps> = object({
hostname: string(), isDisabled: boolean(),
isDisabled: boolean() hostname: string()
}) })
interface EditAccountFormProps { interface EditAccountFormProps {
@ -95,7 +95,7 @@ const EditAccount: FC<EditAccountProps> = (props) => {
...RegisterFormProto(), ...RegisterFormProto(),
isDisabled: response.isDisabled, isDisabled: response.isDisabled,
description: response.description, description: response.description,
contacts: response.contacts, contacts: [...response.contacts],
hostnames: (response.hostnames ?? []).map(h => ({ hostnames: (response.hostnames ?? []).map(h => ({
...EditAccountHostnameFormProto(), ...EditAccountHostnameFormProto(),
isDisabled: h.isDisabled, isDisabled: h.isDisabled,
@ -124,9 +124,10 @@ const EditAccount: FC<EditAccountProps> = (props) => {
const patchRequest: PatchAccountRequest = { const patchRequest: PatchAccountRequest = {
isDisabled: formStateCopy.isDisabled, isDisabled: formStateCopy.isDisabled,
description: formStateCopy.description, description: formStateCopy.description,
contacts: formStateCopy.contacts, contacts: [...formStateCopy.contacts],
hostnames: formStateCopy.hostnames.map(h => ({ hostnames: formStateCopy.hostnames.map(h => ({
hostname: h.hostname hostname: h.hostname,
isDisabled: h.isDisabled
})) }))
} }
@ -139,7 +140,11 @@ const EditAccount: FC<EditAccountProps> = (props) => {
const fromFormState = mapFormStateToPatchRequest(formState) const fromFormState = mapFormStateToPatchRequest(formState)
const fromBackupState = mapFormStateToPatchRequest(backupState) const fromBackupState = mapFormStateToPatchRequest(backupState)
const delta = deepDelta(fromBackupState, fromFormState) const delta = deepDelta(fromFormState, fromBackupState, {
arrays: {
hostnames: { identityKey: 'hostname' }
}
})
if (!deltaHasOperations(delta)) { if (!deltaHasOperations(delta)) {
addToast('No changes detected', 'info') addToast('No changes detected', 'info')
@ -147,6 +152,7 @@ const EditAccount: FC<EditAccountProps> = (props) => {
} }
const request = PatchAccountRequestSchema.safeParse(delta) const request = PatchAccountRequestSchema.safeParse(delta)
if (!request.success) { if (!request.success) {
request.error.issues.forEach(error => { request.error.issues.forEach(error => {
addToast(error.message, 'error') addToast(error.message, 'error')
@ -156,7 +162,7 @@ const EditAccount: FC<EditAccountProps> = (props) => {
} }
patchData<PatchAccountRequest, GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_PATCH).route patchData<PatchAccountRequest, GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_PATCH).route
.replace('{accountId}', accountId), delta .replace('{accountId}', accountId), delta, 120000
).then((response) => { ).then((response) => {
if (!response) return if (!response) return
@ -215,9 +221,6 @@ const EditAccount: FC<EditAccountProps> = (props) => {
label={'New Contact'} label={'New Contact'}
value={formState.contact} value={formState.contact}
onChange={(e) => { onChange={(e) => {
if (formState.contacts.includes(e.target.value))
return
handleInputChange('contact', e.target.value) handleInputChange('contact', e.target.value)
}} }}
placeholder={'Add contact'} placeholder={'Add contact'}
@ -227,6 +230,9 @@ const EditAccount: FC<EditAccountProps> = (props) => {
<FieldContainer colspan={2}> <FieldContainer colspan={2}>
<ButtonComponent <ButtonComponent
onClick={() => { onClick={() => {
if (formState.contacts.includes(formState.contact))
return
handleInputChange('contacts', [...formState.contacts, formState.contact]) handleInputChange('contacts', [...formState.contacts, formState.contact])
handleInputChange('contact', '') handleInputChange('contact', '')
}} }}
@ -238,12 +244,31 @@ const EditAccount: FC<EditAccountProps> = (props) => {
<h3 className={'col-span-12'}>Hostnames:</h3> <h3 className={'col-span-12'}>Hostnames:</h3>
<ul className={'col-span-12'}> <ul className={'col-span-12'}>
{formState.hostnames.map((hostname) => ( {formState.hostnames.map((hostname) => (
<li key={hostname.hostname} className={'grid grid-cols-12 gap-4 w-full'}> <li key={hostname.hostname} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
<span className={'col-span-10'}>{hostname.hostname}</span> <span className={'col-span-7'}>{hostname.hostname}</span>
<span className={'col-span-3'}>
<label className={'mr-2'}>Disabled:</label>
<input
type={'checkbox'}
checked={hostname.isDisabled}
onChange={(e) => {
const updatedHostnames = formState.hostnames.map(h => {
if (h.hostname === hostname.hostname) {
return {
...h,
isDisabled: e.target.checked
}
}
return h
})
handleInputChange('hostnames', updatedHostnames)
}}
/>
</span>
<ButtonComponent <ButtonComponent
colspan={2} colspan={2}
onClick={() => { onClick={() => {
const updatedHostnames = formState.hostnames.filter(h => h !== hostname) const updatedHostnames = formState.hostnames.filter(h => h.hostname !== hostname.hostname)
handleInputChange('hostnames', updatedHostnames) handleInputChange('hostnames', updatedHostnames)
}} }}
> >
@ -258,9 +283,6 @@ const EditAccount: FC<EditAccountProps> = (props) => {
label={'New Hostname'} label={'New Hostname'}
value={formState.hostname} value={formState.hostname}
onChange={(e) => { onChange={(e) => {
if (formState.hostnames.find(h => h.hostname === e.target.value))
return
handleInputChange('hostname', e.target.value) handleInputChange('hostname', e.target.value)
}} }}
placeholder={'Add hostname'} placeholder={'Add hostname'}
@ -270,7 +292,15 @@ const EditAccount: FC<EditAccountProps> = (props) => {
<FieldContainer colspan={2}> <FieldContainer colspan={2}>
<ButtonComponent <ButtonComponent
onClick={() => { onClick={() => {
handleInputChange('hostnames', [...formState.hostnames, formState.hostname]) if (formState.hostnames.find(h => h.hostname === formState.hostname))
return
handleInputChange('hostnames', [ ...formState.hostnames, {
...EditAccountHostnameFormProto(),
hostname: formState.hostname
}
])
handleInputChange('hostname', '') handleInputChange('hostname', '')
}} }}
disabled={formState.hostname.trim() === ''} disabled={formState.hostname.trim() === ''}

View File

@ -16,7 +16,7 @@ const Home: FC = () => {
const [accountId, setAccountId] = useState<string | undefined>(undefined) const [accountId, setAccountId] = useState<string | undefined>(undefined)
const loadData = useCallback(() => { const loadData = useCallback(() => {
getData<GetAccountResponse[]>(GetApiRoute(ApiRoutes.ACCOUNTS).route).then((response) => { getData<GetAccountResponse[]>(GetApiRoute(ApiRoutes.ACCOUNTS_GET).route).then((response) => {
if (!response) return if (!response) return
setRawd(response) setRawd(response)
}) })

View File

@ -1,9 +1,12 @@
import { FC, useState } from 'react' import { FC, useState } from 'react'
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout' import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
import { ButtonComponent, DateTimePickerComponent, FileUploadComponent } from '../components/editors' import { ButtonComponent, FileUploadComponent } from '../components/editors'
import { ApiRoutes, GetApiRoute } from '../AppMap' import { ApiRoutes, GetApiRoute } from '../AppMap'
import { getData } from '../axiosConfig' import { deleteData, getBinary, getData, postFile } from '../axiosConfig'
import { addToast } from '../components/Toast/addToast' import { addToast } from '../components/Toast/addToast'
import { extractFilenameFromHeaders, saveBinaryToDisk } from '../functions'
import { downloadZip } from 'client-zip'
const Utilities: FC = () => { const Utilities: FC = () => {
@ -18,6 +21,40 @@ const Utilities: FC = () => {
}) })
} }
const handleUploadFiles = async () => {
if (files.length === 0) {
addToast('No files selected for upload', 'error')
return
}
const zipBlob = await downloadZip(files).blob()
// Option A: direct file helper
postFile(GetApiRoute(ApiRoutes.FULL_CACHE_UPLOAD_POST).route, zipBlob, 'file', 'cache.zip')
.then((_) => {
setFiles([])
addToast('Files uploaded successfully', 'success')
})
}
const handleDownloadFiles = () => {
getBinary(GetApiRoute(ApiRoutes.FULL_CACHE_DOWNLOAD_GET).route
).then((response) => {
if (!response) return
const { data, headers } = response
const filename = extractFilenameFromHeaders(headers, 'cache.zip')
saveBinaryToDisk(data, filename)
})
}
const handleDestroyFiles = () => {
deleteData(GetApiRoute(ApiRoutes.FULL_CACHE_DELETE).route)
.then((_) => {
addToast('Cache files destroyed successfully', 'success')
})
}
return <FormContainer> return <FormContainer>
<FormHeader>Utilities</FormHeader> <FormHeader>Utilities</FormHeader>
<FormContent> <FormContent>
@ -29,25 +66,37 @@ const Utilities: FC = () => {
onClick={hadnleTestAgent} onClick={hadnleTestAgent}
/> />
<span className={'col-span-9'}></span>
<FileUploadComponent <FileUploadComponent
colspan={6} colspan={6}
label={'Upload cache files'} label={'Select cache files'}
multiple={true} multiple={true}
onChange={setFiles} onChange={setFiles}
/> />
<ButtonComponent
colspan={3}
children={'Upload cache files'}
buttonHierarchy={'primary'}
onClick={handleUploadFiles}
/>
<span className={'col-span-3'}></span>
<ButtonComponent <ButtonComponent
colspan={3} colspan={3}
children={'Download cache files'} children={'Download cache files'}
buttonHierarchy={'secondary'} buttonHierarchy={'secondary'}
onClick={() => {}} onClick={handleDownloadFiles}
/> />
<ButtonComponent <ButtonComponent
colspan={3} colspan={3}
children={'Destroy cache files'} children={'Destroy cache files'}
buttonHierarchy={'error'} buttonHierarchy={'error'}
onClick={() => {}} onClick={handleDestroyFiles}
/> />
</div> </div>
</FormContent> </FormContent>

View File

@ -1,4 +1,4 @@
import { PatchOperation } from '../../models/PatchOperation' import { PatchOperation } from '../../models/PatchOperation.js'
import { deepCopy } from './deepCopy.js' import { deepCopy } from './deepCopy.js'
import { deepEqual } from './deepEqual.js' import { deepEqual } from './deepEqual.js'
@ -18,37 +18,71 @@ type PlainObject = Record<string, unknown>
type DeltaArrayItem<T extends Identifiable> = Partial<T> & EnsureId<T> & OperationBag type DeltaArrayItem<T extends Identifiable> = Partial<T> & EnsureId<T> & OperationBag
/** Policy non-generica: chiavi sempre stringhe */ /**
* Policy that controls how object arrays behave.
*
* - Arrays with identifiable items (id or identityKey) get per-item Add/Remove/Update logic.
* - Arrays without identity fall back to "full replace" semantics.
*/
export type ArrayPolicy = { export type ArrayPolicy = {
/** Nome del campo “radice” che implica re-parenting (es. 'organizationId') */ /** Name of the "root" field that implies re-parenting (e.g. 'organizationId') */
rootKey?: string rootKey?: string
/** Nomi degli array figli da trattare in caso di re-parenting (es. ['applicationRoles']) */
/** Child array field names to process on re-parenting (e.g. ['applicationRoles']) */
childArrayKeys?: string[] childArrayKeys?: string[]
/** Se true, in re-parenting i figli vengono azzerati (default TRUE) */
/** If true, children are cleared on root change (default TRUE) */
dropChildrenOnRootChange?: boolean dropChildrenOnRootChange?: boolean
/** Nome del campo ruolo (default 'role') */
/** Name of the role field (default 'role') */
roleFieldKey?: string roleFieldKey?: string
/** Se true, quando role diventa null si rimuove lintero item (default TRUE) */
/** If true, when role becomes null the entire item is removed (default TRUE) */
deleteItemWhenRoleRemoved?: boolean deleteItemWhenRoleRemoved?: boolean
/**
* Stable identity for items that do not have an `id`.
* Can be:
* - a property name (e.g. "hostname")
* - a function that extracts a unique value
*
* Without identityKey AND without item.id, the array falls back to full replace.
*/
identityKey?: string | ((item: Record<string, unknown>) => string | number)
} }
export type DeepDeltaOptions<T> = { export type DeepDeltaOptions<T> = {
/** Policy per i campi array del payload (mappati per nome chiave) */ /**
* Optional per-array rules.
* Example:
* {
* hostnames: { identityKey: "hostname" }
* }
*/
arrays?: Partial<Record<Extract<keyof T, string>, ArrayPolicy>> arrays?: Partial<Record<Extract<keyof T, string>, ArrayPolicy>>
} }
/**
* Delta<T> represents:
* - T fields that changed (primitives, objects, arrays)
* - "operations" dictionary describing what type of change (SetField, RemoveField, AddToCollection, etc.)
* - For primitive arrays: delta contains the full new array + SetField.
* - For identifiable object arrays: delta contains per-item changes.
*/
export type Delta<T> = export type Delta<T> =
Partial<{ Partial<{
[K in keyof T]: [K in keyof T]:
T[K] extends (infer U)[] T[K] extends (infer U)[]
? DeltaArrayItem<(U & Identifiable)>[] ? (U extends object
? DeltaArrayItem<(U & Identifiable)>[] // object arrays → itemized
: U[]) // primitive arrays → full array
: T[K] extends object : T[K] extends object
? Delta<T[K] & OperationBag<Extract<keyof T, string>>> ? Delta<T[K] & OperationBag<Extract<keyof T, string>>>
: T[K] : T[K]
}> & OperationBag<Extract<keyof T, string>> }> & OperationBag<Extract<keyof T, string>>
/** Safe index per evitare TS2536 quando si indicizza su chiavi dinamiche */ /** Safe index to avoid TS2536 when addressing dynamic keys */
const getArrayPolicy = <T>(options: DeepDeltaOptions<T> | undefined, key: string): ArrayPolicy | undefined =>{ const getArrayPolicy = <T>(options: DeepDeltaOptions<T> | undefined, key: string): ArrayPolicy | undefined => {
const arrays = options?.arrays as Partial<Record<string, ArrayPolicy>> | undefined const arrays = options?.arrays as Partial<Record<string, ArrayPolicy>> | undefined
return arrays?.[key] return arrays?.[key]
} }
@ -56,6 +90,16 @@ const getArrayPolicy = <T>(options: DeepDeltaOptions<T> | undefined, key: string
const isPlainObject = (value: unknown): value is PlainObject => const isPlainObject = (value: unknown): value is PlainObject =>
typeof value === 'object' && value !== null && !Array.isArray(value) typeof value === 'object' && value !== null && !Array.isArray(value)
/**
* Computes a deep "delta" object between formState and backupState.
*
* Rules:
* - Primitive fields SetField / RemoveField
* - Primitive arrays full replace (SetField)
* - Object arrays:
* * if items have id or identityKey itemized collection diff
* * otherwise full replace (SetField)
*/
export const deepDelta = <T extends Record<string, unknown>>( export const deepDelta = <T extends Record<string, unknown>>(
formState: T, formState: T,
backupState: T, backupState: T,
@ -63,11 +107,20 @@ export const deepDelta = <T extends Record<string, unknown>>(
): Delta<T> => { ): Delta<T> => {
const delta = {} as Delta<T> const delta = {} as Delta<T>
// Sets an operation flag into the provided bag for a given key
const setOp = (bag: OperationBag, key: string, op: PatchOperation) => { const setOp = (bag: OperationBag, key: string, op: PatchOperation) => {
const ops = (bag.operations ??= {} as Record<string, PatchOperation>) const ops = (bag.operations ??= {} as Record<string, PatchOperation>)
ops[key] = op ops[key] = op
} }
/**
* Recursive object diffing.
*
* Handles:
* - primitives
* - nested objects
* - arrays (delegates to array logic)
*/
const calculateDelta = ( const calculateDelta = (
form: PlainObject, form: PlainObject,
backup: PlainObject, backup: PlainObject,
@ -82,18 +135,59 @@ export const deepDelta = <T extends Record<string, unknown>>(
// --- ARRAY --- // --- ARRAY ---
if (Array.isArray(formValue) && Array.isArray(backupValue)) { if (Array.isArray(formValue) && Array.isArray(backupValue)) {
const bothPrimitive =
(formValue as unknown[]).every(v => typeof v !== 'object' || v === null) &&
(backupValue as unknown[]).every(v => typeof v !== 'object' || v === null)
/**
* Detect primitive arrays (string[], number[], primitive unions).
* Primitive arrays have no identity always full replace.
*/
if (bothPrimitive) {
if (!deepEqual(formValue, backupValue)) {
;(parentDelta as Delta<T>)[key] = deepCopy(formValue) as unknown as Delta<T>[typeof key]
setOp(parentDelta, key, PatchOperation.SetField)
}
continue
}
// Object collections
const policy = getArrayPolicy(options, key) const policy = getArrayPolicy(options, key)
/**
* If items have neither `id` nor `identityKey`, they cannot be diffed.
* => treat array as a scalar and replace entirely.
*/
const lacksIdentity =
!(policy?.identityKey) &&
(formValue as Identifiable[]).every(x => (x?.id ?? null) == null) &&
(backupValue as Identifiable[]).every(x => (x?.id ?? null) == null)
if (lacksIdentity) {
if (!deepEqual(formValue, backupValue)) {
;(parentDelta as Delta<T>)[key] = deepCopy(formValue) as unknown as Delta<T>[typeof key]
setOp(parentDelta, key, PatchOperation.SetField)
}
continue
}
/**
* Identifiable arrays => itemized delta with Add/Remove/Update
*/
const arrayDelta = calculateArrayDelta( const arrayDelta = calculateArrayDelta(
formValue as Identifiable[], formValue as Identifiable[],
backupValue as Identifiable[], backupValue as Identifiable[],
policy policy
) )
if (arrayDelta.length > 0) { if (arrayDelta.length > 0) {
;(parentDelta as Delta<T>)[key] = arrayDelta as unknown as Delta<T>[typeof key] ;(parentDelta as Delta<T>)[key] = arrayDelta as unknown as Delta<T>[typeof key]
} }
continue continue
} }
// --- OBJECT --- // --- OBJECT ---
if (isPlainObject(formValue) && isPlainObject(backupValue)) { if (isPlainObject(formValue) && isPlainObject(backupValue)) {
if (!deepEqual(formValue, backupValue)) { if (!deepEqual(formValue, backupValue)) {
@ -118,6 +212,16 @@ export const deepDelta = <T extends Record<string, unknown>>(
} }
} }
/**
* Computes itemized delta for identifiable object arrays.
*
* Handles:
* - Add: item without id or identity
* - Remove: item missing in formArray
* - Update: fields changed inside item
* - Re-parenting: rootKey changed
* - Role: if policy.deleteItemWhenRoleRemoved is true
*/
const calculateArrayDelta = <U extends Identifiable>( const calculateArrayDelta = <U extends Identifiable>(
formArray: U[], formArray: U[],
backupArray: U[], backupArray: U[],
@ -125,7 +229,28 @@ export const deepDelta = <T extends Record<string, unknown>>(
): DeltaArrayItem<U>[] => { ): DeltaArrayItem<U>[] => {
const arrayDelta: DeltaArrayItem<U>[] = [] const arrayDelta: DeltaArrayItem<U>[] = []
const getId = (item?: U): IdLike => (item ? item.id ?? null : null) /**
* Identity resolution order:
* 1. If item has `.id` use it.
* 2. Else if identityKey is provided use that to extract a unique key.
* 3. Else: return null item will be treated as new.
*/
const resolveId = (item?: U): IdLike => {
if (!item) return null
const directId = (item as Identifiable).id
if (directId !== null && directId !== undefined) return directId
if (!policy?.identityKey) return null
if (typeof policy.identityKey === 'function') {
try { return policy.identityKey(item as unknown as Record<string, unknown>) }
catch { return null }
}
const k = policy.identityKey as string
const v = (item as unknown as Record<string, unknown>)[k]
return (typeof v === 'string' || typeof v === 'number') ? v : null
}
const childrenKeys = policy?.childArrayKeys ?? [] const childrenKeys = policy?.childArrayKeys ?? []
const dropChildren = policy?.dropChildrenOnRootChange ?? true const dropChildren = policy?.dropChildrenOnRootChange ?? true
const roleKey = (policy?.roleFieldKey ?? 'role') as keyof U & string const roleKey = (policy?.roleFieldKey ?? 'role') as keyof U & string
@ -136,29 +261,29 @@ export const deepDelta = <T extends Record<string, unknown>>(
return (f as PlainObject)[rootKey] === (b as PlainObject)[rootKey] return (f as PlainObject)[rootKey] === (b as PlainObject)[rootKey]
} }
// Mappe id → item per lookup veloce // id → item maps for O(1) lookups
const formMap = new Map<string | number, U>() const formMap = new Map<string | number, U>()
const backupMap = new Map<string | number, U>() const backupMap = new Map<string | number, U>()
for (const item of formArray) { for (const item of formArray) {
const id = getId(item) const id = resolveId(item)
if (id !== null && id !== undefined) formMap.set(id as string | number, item) if (id !== null && id !== undefined) formMap.set(id as string | number, item)
} }
for (const item of backupArray) { for (const item of backupArray) {
const id = getId(item) const id = resolveId(item)
if (id !== null && id !== undefined) backupMap.set(id as string | number, item) if (id !== null && id !== undefined) backupMap.set(id as string | number, item)
} }
// 1) Gestione elementi presenti nel form // 1) Items present in the form array
for (const formItem of formArray) { for (const formItem of formArray) {
const fid = getId(formItem) const fid = resolveId(formItem)
// 1.a) Nuovo item (senza id) // 1.a) New item (no identity)
if (fid === null || fid === undefined) { if (fid === null || fid === undefined) {
const addItem = {} as DeltaArrayItem<U> const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>) Object.assign(addItem, formItem as Partial<U>)
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection } addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
// ⬇️ NON droppiamo i figli su "add": li normalizziamo come AddToCollection // normalize children as AddToCollection
for (const ck of childrenKeys) { for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck] const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) { if (Array.isArray(v)) {
@ -168,7 +293,7 @@ export const deepDelta = <T extends Record<string, unknown>>(
c.operations = { collectionItemOperation: PatchOperation.AddToCollection } c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
return c return c
}) })
;(addItem as PlainObject)[ck] = normalized ;(addItem as PlainObject)[ck] = normalized
} }
} }
@ -176,15 +301,14 @@ export const deepDelta = <T extends Record<string, unknown>>(
continue continue
} }
// 1.b) Ha id ma non esiste nel backup ⇒ AddToCollection // 1.b) Has identity but not in backup ⇒ AddToCollection
const backupItem = backupMap.get(fid as string | number) const backupItem = backupMap.get(fid as string | number)
if (!backupItem) { if (!backupItem) {
const addItem = {} as DeltaArrayItem<U> const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>) Object.assign(addItem, formItem as Partial<U>)
addItem.id = fid as U['id'] addItem.id = fid as U['id'] // store identity for server convenience
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection } addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
// ⬇️ Anche qui: manteniamo i figli, marcandoli come AddToCollection
for (const ck of childrenKeys) { for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck] const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) { if (Array.isArray(v)) {
@ -194,7 +318,7 @@ export const deepDelta = <T extends Record<string, unknown>>(
c.operations = { collectionItemOperation: PatchOperation.AddToCollection } c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
return c return c
}) })
;(addItem as PlainObject)[ck] = normalized ;(addItem as PlainObject)[ck] = normalized
} }
} }
@ -202,28 +326,24 @@ export const deepDelta = <T extends Record<string, unknown>>(
continue continue
} }
// 1.c) Re-parenting: root cambiata // 1.c) Re-parenting: root changed
if (!sameRoot(formItem, backupItem)) { if (!sameRoot(formItem, backupItem)) {
// REMOVE vecchio
const removeItem = {} as DeltaArrayItem<U> const removeItem = {} as DeltaArrayItem<U>
removeItem.id = fid as U['id'] removeItem.id = fid as U['id']
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection } removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
arrayDelta.push(removeItem) arrayDelta.push(removeItem)
// ADD nuovo
const addItem = {} as DeltaArrayItem<U> const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>) Object.assign(addItem, formItem as Partial<U>)
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection } addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
if (dropChildren) { if (dropChildren) {
// ⬇️ SOLO qui, in caso di re-parenting e se richiesto, azzera i figli
for (const ck of childrenKeys) { for (const ck of childrenKeys) {
if (ck in (addItem as PlainObject)) { if (ck in (addItem as PlainObject)) {
;(addItem as PlainObject)[ck] = [] ;(addItem as PlainObject)[ck] = []
} }
} }
} else { } else {
// Mantieni i figli marcandoli come AddToCollection
for (const ck of childrenKeys) { for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck] const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) { if (Array.isArray(v)) {
@ -232,7 +352,8 @@ export const deepDelta = <T extends Record<string, unknown>>(
Object.assign(c, child as Partial<Identifiable>) Object.assign(c, child as Partial<Identifiable>)
c.operations = { collectionItemOperation: PatchOperation.AddToCollection } c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
return c return c
}); (addItem as PlainObject)[ck] = normalized })
;(addItem as PlainObject)[ck] = normalized
} }
} }
} }
@ -241,8 +362,7 @@ export const deepDelta = <T extends Record<string, unknown>>(
continue continue
} }
// 1.d) Role → null ⇒ remove item (if enabled)
// 1.d) Ruolo → null ⇒ rimozione item (se abilitato)
const deleteOnRoleNull = policy?.deleteItemWhenRoleRemoved ?? true const deleteOnRoleNull = policy?.deleteItemWhenRoleRemoved ?? true
if (deleteOnRoleNull) { if (deleteOnRoleNull) {
const formRole = (formItem as PlainObject)[roleKey] const formRole = (formItem as PlainObject)[roleKey]
@ -257,14 +377,14 @@ export const deepDelta = <T extends Record<string, unknown>>(
} }
} }
// 1.e) Diff puntuale su campi // 1.e) Field-level diff
const itemDeltaBase = {} as (PlainObject & OperationBag & { id?: U['id'] }) const itemDeltaBase = {} as (PlainObject & OperationBag & { id?: U['id'] })
itemDeltaBase.id = fid as U['id'] itemDeltaBase.id = fid as U['id']
calculateDelta( calculateDelta(
formItem as PlainObject, formItem as PlainObject,
backupItem as PlainObject, backupItem as PlainObject,
itemDeltaBase itemDeltaBase
) )
const hasMeaningfulChanges = Object.keys(itemDeltaBase).some(k => k !== 'id') const hasMeaningfulChanges = Object.keys(itemDeltaBase).some(k => k !== 'id')
@ -273,9 +393,9 @@ export const deepDelta = <T extends Record<string, unknown>>(
} }
} }
// 2) Elementi rimossi // 2) Items removed
for (const backupItem of backupArray) { for (const backupItem of backupArray) {
const bid = getId(backupItem) const bid = resolveId(backupItem)
if (bid === null || bid === undefined) continue if (bid === null || bid === undefined) continue
if (!formMap.has(bid as string | number)) { if (!formMap.has(bid as string | number)) {
const removeItem = {} as DeltaArrayItem<U> const removeItem = {} as DeltaArrayItem<U>
@ -297,6 +417,14 @@ export const deepDelta = <T extends Record<string, unknown>>(
return delta return delta
} }
/**
* Checks whether any operations exist inside the delta.
*
* A delta has operations if:
* - parent-level operations exist, or
* - nested object deltas contain operations, or
* - any array item contains operations.
*/
export const deltaHasOperations = <T extends Record<string, unknown>>(delta: Delta<T>): boolean => { export const deltaHasOperations = <T extends Record<string, unknown>>(delta: Delta<T>): boolean => {
if (!isPlainObject(delta)) return false if (!isPlainObject(delta)) return false
if ('operations' in delta && isPlainObject(delta.operations)) return true if ('operations' in delta && isPlainObject(delta.operations)) return true

View File

@ -0,0 +1,7 @@
import {
saveBinaryToDisk
} from './saveBinaryToDisk'
export {
saveBinaryToDisk
}

View File

@ -0,0 +1,23 @@
/**
* Saves binary data to disk by creating a downloadable link.
* @param data The binary data to save (ArrayBuffer or Blob).
* @param filename The desired filename for the saved file.
*/
const saveBinaryToDisk = (data: ArrayBuffer | Blob, filename: string) => {
const blob = data instanceof Blob ? data : new Blob([data])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
export {
saveBinaryToDisk
}

View File

@ -0,0 +1,44 @@
/**
* Extracts filename from HTTP headers.
* @param headers The HTTP headers object.
* @param fallbackName The fallback filename if none found in headers.
* @return The extracted filename or the fallback name.
*/
const extractFilenameFromHeaders = (
headers: Record<string, string>,
fallbackName: string = 'download.bin'
): string => {
const cd = headers['content-disposition']
if (!cd) {
return fallbackName
}
// RFC 5987 — filename*=UTF-8''encoded-name
const matchEncoded = /filename\*=\s*UTF-8''([^;]+)/i.exec(cd)
if (matchEncoded && matchEncoded[1]) {
try {
return decodeURIComponent(matchEncoded[1])
} catch {
return matchEncoded[1]
}
}
// Standard — filename="quoted"
const matchQuoted = /filename="([^"]+)"/i.exec(cd)
if (matchQuoted && matchQuoted[1]) {
return matchQuoted[1]
}
// Standard — filename=plain
const matchPlain = /filename=([^;]+)/i.exec(cd)
if (matchPlain && matchPlain[1]) {
return matchPlain[1].trim()
}
return fallbackName
}
export {
extractFilenameFromHeaders
}

View File

@ -0,0 +1,7 @@
import {
extractFilenameFromHeaders
} from './extractFilenameFromHeaders'
export {
extractFilenameFromHeaders
}

View File

@ -31,10 +31,20 @@ import {
parseAclEntries parseAclEntries
} from './acl' } from './acl'
import {
saveBinaryToDisk
} from './file'
import {
extractFilenameFromHeaders
} from './headers'
export { export {
// date
isValidISODateString, isValidISODateString,
formatISODateString, formatISODateString,
// deep
deepCopy, deepCopy,
deepDelta, deepDelta,
deltaHasOperations, deltaHasOperations,
@ -42,6 +52,7 @@ export {
deepMerge, deepMerge,
deepPatternMatch, deepPatternMatch,
// enum
enumToArr, enumToArr,
enumToObj, enumToObj,
enumToString, enumToString,
@ -50,8 +61,16 @@ export {
hasFlag, hasFlag,
hasAnyFlag, hasAnyFlag,
// isGuid
isGuid, isGuid,
// acl
parseAclEntry, parseAclEntry,
parseAclEntries parseAclEntries,
// file
saveBinaryToDisk,
// headers
extractFilenameFromHeaders
} }

View File

@ -5,7 +5,7 @@ namespace MaksIT.Models.LetsEncryptServer.Account.Requests;
public class PatchAccountRequest : PatchRequestModelBase { public class PatchAccountRequest : PatchRequestModelBase {
public string Description { get; set; } public string? Description { get; set; }
public bool? IsDisabled { get; set; } public bool? IsDisabled { get; set; }

View File

@ -11,7 +11,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.5.1" /> <PackageReference Include="MaksIT.Core" Version="1.5.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>