From 0a1b741acfb938d2ec829a14ebdba5c3a2454387 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 21 Mar 2026 12:28:52 +0100 Subject: [PATCH 1/8] feat: Refactor services to use reactive configuration and enhance logging --- src/Cocoar.Shelf/Cocoar.Shelf.csproj | 2 + src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs | 211 ++++++++++++++---- src/Cocoar.Shelf/Endpoints/ApiKeyFilter.cs | 37 ++- .../Middleware/DocsRoutingMiddleware.cs | 14 +- src/Cocoar.Shelf/Program.cs | 61 +++-- src/Cocoar.Shelf/Services/ManifestService.cs | 24 +- .../Services/ProductConfigService.cs | 6 +- src/Cocoar.Shelf/Services/UploadService.cs | 22 +- src/Cocoar.Shelf/appsettings.json | 16 +- src/Directory.Packages.props | 2 + .../Cocoar.Shelf.Tests/ApiKeyFilterTests.cs | 6 +- .../ManifestServiceTests.cs | 9 +- .../ProductConfigServiceTests.cs | 5 +- .../Cocoar.Shelf.Tests/TestReactiveConfig.cs | 19 ++ .../Cocoar.Shelf.Tests/UploadServiceTests.cs | 5 +- 15 files changed, 326 insertions(+), 113 deletions(-) create mode 100644 src/tests/Cocoar.Shelf.Tests/TestReactiveConfig.cs diff --git a/src/Cocoar.Shelf/Cocoar.Shelf.csproj b/src/Cocoar.Shelf/Cocoar.Shelf.csproj index 97a625f..4822782 100644 --- a/src/Cocoar.Shelf/Cocoar.Shelf.csproj +++ b/src/Cocoar.Shelf/Cocoar.Shelf.csproj @@ -5,7 +5,9 @@ + + diff --git a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs index 153c375..1057c85 100644 --- a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs +++ b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs @@ -1,11 +1,10 @@ using System.Text.RegularExpressions; using Cocoar.Shelf.Services; using Microsoft.AspNetCore.Http.Features; -using Microsoft.Extensions.Options; namespace Cocoar.Shelf.Endpoints; -public static class ApiEndpoints +public static partial class ApiEndpoints { public static WebApplication MapApiEndpoints(this WebApplication app) { @@ -19,42 +18,67 @@ public static WebApplication MapApiEndpoints(this WebApplication app) return app; } - private static IResult GetProducts(IProductConfigService configService, IManifestService manifestService) + private static IResult GetProducts( + IProductConfigService configService, + IManifestService manifestService, + ILoggerFactory loggerFactory) { - var products = configService.GetAll().Select(config => + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try { - var manifest = manifestService.GetManifest(config.Name); - return new + var products = configService.GetAll().Select(config => { - config.Name, - config.DisplayName, - config.Description, - config.Source, - Latest = manifest?.Latest, - Versions = manifest?.Versions ?? (IReadOnlyList)[] - }; - }); - - return Results.Ok(products); + var manifest = manifestService.GetManifest(config.Name); + return new + { + config.Name, + config.DisplayName, + config.Description, + config.Source, + Latest = manifest?.Latest, + Versions = manifest?.Versions ?? (IReadOnlyList)[] + }; + }); + + return Results.Ok(products); + } + catch (Exception ex) + { + LogListProductsFailed(logger, ex); + return Results.Json(new { error = "Failed to list products" }, statusCode: 500); + } } private static IResult GetVersions( string product, IProductConfigService configService, - IManifestService manifestService) + IManifestService manifestService, + ILoggerFactory loggerFactory) { - var config = configService.GetConfig(product); - if (config == null) - return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var config = configService.GetConfig(product); + if (config == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } - var manifest = manifestService.GetManifest(product); + var manifest = manifestService.GetManifest(product); - return Results.Ok(new + return Results.Ok(new + { + Name = product, + Latest = manifest?.Latest, + Versions = manifest?.Versions ?? (IReadOnlyList)[] + }); + } + catch (Exception ex) { - Name = product, - Latest = manifest?.Latest, - Versions = manifest?.Versions ?? (IReadOnlyList)[] - }); + LogGetVersionsFailed(logger, product, ex); + return Results.Json(new { error = $"Failed to get versions for product '{product}'" }, statusCode: 500); + } } private static async Task UploadVersion( @@ -63,11 +87,42 @@ private static async Task UploadVersion( HttpContext httpContext, IProductConfigService configService, IUploadService uploadService, - IOptions options, + ShelfOptions options, + ILoggerFactory loggerFactory, CancellationToken ct) { - var opts = options.Value; + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + return await UploadVersionCore(product, version, httpContext, configService, uploadService, options, logger, ct); + } + catch (BadHttpRequestException ex) + { + LogUploadBadRequest(logger, product, version, ex); + return Results.Json(new { error = $"Bad request: {ex.Message}" }, statusCode: 400); + } + catch (OperationCanceledException) + { + LogUploadCancelled(logger, product, version); + return Results.Json(new { error = "Upload cancelled" }, statusCode: 499); + } + catch (Exception ex) + { + LogUploadFailed(logger, product, version, ex); + return Results.Json(new { error = $"Upload failed: {ex.Message}" }, statusCode: 500); + } + } + private static async Task UploadVersionCore( + string product, + string version, + HttpContext httpContext, + IProductConfigService configService, + IUploadService uploadService, + ShelfOptions opts, + ILogger logger, + CancellationToken ct) + { // Increase request body size limit for this endpoint var maxSizeFeature = httpContext.Features.Get(); if (maxSizeFeature is { IsReadOnly: false }) @@ -76,15 +131,32 @@ private static async Task UploadVersion( // Check product is registered var config = configService.GetConfig(product); if (config == null) + { + LogUploadProductNotRegistered(logger, product); return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } // Validate version format + if (string.IsNullOrWhiteSpace(version)) + { + LogUploadEmptyVersion(logger, product); + return Results.Json(new { error = "Version must not be empty" }, statusCode: 400); + } + if (!Regex.IsMatch(version, opts.VersionPattern)) - return Results.Json(new { error = $"Invalid version format: '{version}'" }, statusCode: 400); + { + LogUploadInvalidVersion(logger, version, product, opts.VersionPattern); + return Results.Json( + new { error = $"Invalid version format: '{version}'. Must match pattern: {opts.VersionPattern}" }, + statusCode: 400); + } // Check Content-Length if present if (httpContext.Request.ContentLength > opts.MaxUploadSizeBytes) - return Results.Json(new { error = "Upload exceeds maximum allowed size" }, statusCode: 413); + { + LogUploadTooLarge(logger, httpContext.Request.ContentLength, opts.MaxUploadSizeBytes, product, version); + return Results.Json(new { error = $"Upload exceeds maximum allowed size ({opts.MaxUploadSizeBytes} bytes)" }, statusCode: 413); + } // Read body with size limit using var ms = new MemoryStream(); @@ -96,24 +168,81 @@ private static async Task UploadVersion( { totalRead += bytesRead; if (totalRead > opts.MaxUploadSizeBytes) - return Results.Json(new { error = "Upload exceeds maximum allowed size" }, statusCode: 413); + { + LogUploadBodyTooLarge(logger, opts.MaxUploadSizeBytes, product, version); + return Results.Json(new { error = $"Upload exceeds maximum allowed size ({opts.MaxUploadSizeBytes} bytes)" }, statusCode: 413); + } ms.Write(buffer, 0, bytesRead); } ms.Position = 0; + LogUploadProcessing(logger, product, version, totalRead); + var result = await uploadService.UploadVersionAsync(product, version, ms, ct); - return result.Status switch + switch (result.Status) { - UploadStatus.Success => Results.Created($"{httpContext.Request.PathBase}/api/products/{product}/versions/{version}", null), - UploadStatus.MissingIndexHtml => Results.Json( - new { error = result.Error ?? "Archive must contain an index.html at the root" }, statusCode: 400), - UploadStatus.VersionConflict => Results.Json( - new { error = result.Error ?? "Upload for this version is already in progress" }, statusCode: 409), - UploadStatus.InvalidArchive => Results.Json( - new { error = result.Error ?? "Invalid ZIP archive" }, statusCode: 400), - _ => Results.Json(new { error = "Internal error" }, statusCode: 500) - }; + case UploadStatus.Success: + return Results.Created($"{httpContext.Request.PathBase}/api/products/{product}/versions/{version}", null); + + case UploadStatus.MissingIndexHtml: + LogUploadRejected(logger, product, version, "missing index.html"); + return Results.Json(new { error = result.Error ?? "Archive must contain an index.html at the root" }, statusCode: 400); + + case UploadStatus.VersionConflict: + LogUploadRejected(logger, product, version, "concurrent upload"); + return Results.Json(new { error = result.Error ?? "Upload for this version is already in progress" }, statusCode: 409); + + case UploadStatus.InvalidArchive: + LogUploadRejected(logger, product, version, result.Error ?? "invalid archive"); + return Results.Json(new { error = result.Error ?? "Invalid ZIP archive" }, statusCode: 400); + + default: + LogUploadUnexpectedStatus(logger, product, version, result.Status); + return Results.Json(new { error = result.Error ?? "Internal error" }, statusCode: 500); + } } + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to list products")] + private static partial void LogListProductsFailed(ILogger logger, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Product not registered: {Product}")] + private static partial void LogProductNotRegistered(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to get versions for product {Product}")] + private static partial void LogGetVersionsFailed(ILogger logger, string product, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload bad request for {Product}/{Version}")] + private static partial void LogUploadBadRequest(ILogger logger, string product, string version, Exception ex); + + [LoggerMessage(Level = LogLevel.Information, Message = "Upload cancelled for {Product}/{Version}")] + private static partial void LogUploadCancelled(ILogger logger, string product, string version); + + [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed for {Product}/{Version}")] + private static partial void LogUploadFailed(ILogger logger, string product, string version, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: product {Product} is not registered")] + private static partial void LogUploadProductNotRegistered(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: empty version for product {Product}")] + private static partial void LogUploadEmptyVersion(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: invalid version format {Version} for product {Product} (pattern: {Pattern})")] + private static partial void LogUploadInvalidVersion(ILogger logger, string version, string product, string pattern); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: content length {Length} exceeds limit {Limit} for {Product}/{Version}")] + private static partial void LogUploadTooLarge(ILogger logger, long? length, long limit, string product, string version); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: body exceeds limit {Limit} for {Product}/{Version}")] + private static partial void LogUploadBodyTooLarge(ILogger logger, long limit, string product, string version); + + [LoggerMessage(Level = LogLevel.Information, Message = "Processing upload for {Product}/{Version} ({Bytes} bytes)")] + private static partial void LogUploadProcessing(ILogger logger, string product, string version, long bytes); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected for {Product}/{Version}: {Reason}")] + private static partial void LogUploadRejected(ILogger logger, string product, string version, string reason); + + [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed with unexpected status {Status} for {Product}/{Version}")] + private static partial void LogUploadUnexpectedStatus(ILogger logger, string product, string version, UploadStatus status); } diff --git a/src/Cocoar.Shelf/Endpoints/ApiKeyFilter.cs b/src/Cocoar.Shelf/Endpoints/ApiKeyFilter.cs index 2d5a800..4be496b 100644 --- a/src/Cocoar.Shelf/Endpoints/ApiKeyFilter.cs +++ b/src/Cocoar.Shelf/Endpoints/ApiKeyFilter.cs @@ -1,27 +1,54 @@ -using Microsoft.Extensions.Options; - namespace Cocoar.Shelf.Endpoints; -public class ApiKeyFilter : IEndpointFilter +public partial class ApiKeyFilter(ILogger logger) : IEndpointFilter { public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - var options = context.HttpContext.RequestServices - .GetRequiredService>().Value; + ShelfOptions options; + try + { + options = context.HttpContext.RequestServices.GetRequiredService(); + } + catch (Exception ex) + { + LogConfigResolutionFailed(logger, ex); + return Results.Json(new { error = "Server configuration error" }, statusCode: 500); + } if (string.IsNullOrEmpty(options.ApiKey)) + { + LogNoApiKeyConfigured(logger); return Results.Json(new { error = "Upload is disabled (no API key configured)" }, statusCode: 503); + } var auth = context.HttpContext.Request.Headers.Authorization.ToString(); if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + LogMissingBearerToken(logger); return Results.Json(new { error = "Missing API key" }, statusCode: 401); + } var provided = auth["Bearer ".Length..]; if (provided != options.ApiKey) + { + LogInvalidApiKey(logger); return Results.Json(new { error = "Invalid API key" }, statusCode: 401); + } return await next(context); } + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to resolve ShelfOptions — configuration may be invalid")] + private static partial void LogConfigResolutionFailed(ILogger logger, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: no API key configured")] + private static partial void LogNoApiKeyConfigured(ILogger logger); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: missing Bearer token")] + private static partial void LogMissingBearerToken(ILogger logger); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: invalid API key")] + private static partial void LogInvalidApiKey(ILogger logger); } diff --git a/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs b/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs index 4a33a8e..7083662 100644 --- a/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs +++ b/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs @@ -1,7 +1,7 @@ using System.Text.RegularExpressions; +using Cocoar.Configuration.Reactive; using Cocoar.Shelf.Services; using Microsoft.AspNetCore.StaticFiles; -using Microsoft.Extensions.Options; namespace Cocoar.Shelf.Middleware; @@ -10,7 +10,7 @@ public partial class DocsRoutingMiddleware private readonly RequestDelegate _next; private readonly IManifestService _manifestService; private readonly BasePathDetector _basePathDetector; - private readonly ShelfOptions _options; + private readonly IReactiveConfig _config; private readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); private readonly Regex _versionRegex; @@ -18,13 +18,13 @@ public DocsRoutingMiddleware( RequestDelegate next, IManifestService manifestService, BasePathDetector basePathDetector, - IOptions options) + IReactiveConfig config) { _next = next; _manifestService = manifestService; _basePathDetector = basePathDetector; - _options = options.Value; - _versionRegex = new Regex(_options.VersionPattern, RegexOptions.Compiled); + _config = config; + _versionRegex = new Regex(_config.CurrentValue.VersionPattern, RegexOptions.Compiled); } public async Task InvokeAsync(HttpContext context) @@ -45,7 +45,7 @@ public async Task InvokeAsync(HttpContext context) var segments = path.Split('/', 2); var product = segments[0]; - var productDir = Path.Combine(_options.DocsRoot, product); + var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); if (!Directory.Exists(productDir)) { @@ -91,7 +91,7 @@ public async Task InvokeAsync(HttpContext context) resolvedPath = Path.GetFullPath(resolvedPath); // Security: prevent path traversal outside docs root - var docsRootFull = Path.GetFullPath(_options.DocsRoot); + var docsRootFull = Path.GetFullPath(_config.CurrentValue.DocsRoot); if (!resolvedPath.StartsWith(docsRootFull, StringComparison.OrdinalIgnoreCase)) { context.Response.StatusCode = 400; diff --git a/src/Cocoar.Shelf/Program.cs b/src/Cocoar.Shelf/Program.cs index bb52f17..db71e31 100644 --- a/src/Cocoar.Shelf/Program.cs +++ b/src/Cocoar.Shelf/Program.cs @@ -1,27 +1,56 @@ +using Cocoar.Configuration.AspNetCore; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Reactive; using Cocoar.Shelf; using Cocoar.Shelf.Endpoints; using Cocoar.Shelf.Middleware; using Cocoar.Shelf.Services; -using Microsoft.Extensions.Options; +using System.Globalization; +using Serilog; -var builder = WebApplication.CreateBuilder(args); +Log.Logger = new LoggerConfiguration() + .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) + .CreateBootstrapLogger(); -builder.Services.Configure(builder.Configuration.GetSection("Shelf")); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +try +{ + var builder = WebApplication.CreateBuilder(args); -var app = builder.Build(); + builder.Host.UseSerilog((context, config) => config + .ReadFrom.Configuration(context.Configuration)); -var shelfOptions = app.Services.GetRequiredService>().Value; -if (!string.IsNullOrEmpty(shelfOptions.PathBase)) - app.UsePathBase(shelfOptions.PathBase); + builder.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromFile("appsettings.json").Select("Shelf"), + rules.For().FromEnvironment("Shelf__") + ])); -if (shelfOptions.EnableLandingPage) - app.MapGet("/", LandingPageEndpoint.Render); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); -app.MapApiEndpoints(); -app.UseMiddleware(); + var app = builder.Build(); -app.Run(); + var shelfOptions = app.Services.GetRequiredService>().CurrentValue; + if (!string.IsNullOrEmpty(shelfOptions.PathBase)) + app.UsePathBase(shelfOptions.PathBase); + + if (shelfOptions.EnableLandingPage) + app.MapGet("/", LandingPageEndpoint.Render); + + app.UseSerilogRequestLogging(); + + app.MapApiEndpoints(); + app.UseMiddleware(); + + app.Run(); +} +catch (Exception ex) when (ex is not HostAbortedException) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/src/Cocoar.Shelf/Services/ManifestService.cs b/src/Cocoar.Shelf/Services/ManifestService.cs index 893de40..12f941c 100644 --- a/src/Cocoar.Shelf/Services/ManifestService.cs +++ b/src/Cocoar.Shelf/Services/ManifestService.cs @@ -1,33 +1,33 @@ using System.Collections.Concurrent; using System.Text.RegularExpressions; +using Cocoar.Configuration.Reactive; using Cocoar.FileSystem; using Cocoar.Shelf.Models; -using Microsoft.Extensions.Options; namespace Cocoar.Shelf.Services; public sealed partial class ManifestService : IManifestService, IDisposable { - private readonly ShelfOptions _options; + private readonly IReactiveConfig _config; private readonly ILogger _logger; private readonly ConcurrentDictionary _cache = new(); private readonly ResilientFileSystemMonitor? _monitor; private readonly Regex _versionRegex; - public ManifestService(IOptions options, ILogger logger) + public ManifestService(IReactiveConfig config, ILogger logger) { - _options = options.Value; + _config = config; _logger = logger; - _versionRegex = new Regex(_options.VersionPattern, RegexOptions.Compiled); + _versionRegex = new Regex(_config.CurrentValue.VersionPattern, RegexOptions.Compiled); - if (!Directory.Exists(_options.DocsRoot)) + if (!Directory.Exists(_config.CurrentValue.DocsRoot)) { - LogDocsRootMissing(_options.DocsRoot); + LogDocsRootMissing(_config.CurrentValue.DocsRoot); return; } _monitor = ResilientFileSystemMonitor - .Watch(_options.DocsRoot) + .Watch(_config.CurrentValue.DocsRoot) .IncludeSubdirectories(2) .WithDebounce(500) .OnCreated((_, e) => InvalidateCache(e.FullPath)) @@ -51,7 +51,7 @@ public ManifestService(IOptions options, ILogger if (_cache.TryGetValue(product, out var cached)) return cached; - var productDir = Path.Combine(_options.DocsRoot, product); + var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); if (!Directory.Exists(productDir)) return null; @@ -67,10 +67,10 @@ public ManifestService(IOptions options, ILogger public IReadOnlyList GetProducts() { - if (!Directory.Exists(_options.DocsRoot)) + if (!Directory.Exists(_config.CurrentValue.DocsRoot)) return []; - return Directory.GetDirectories(_options.DocsRoot) + return Directory.GetDirectories(_config.CurrentValue.DocsRoot) .Select(Path.GetFileName) .Where(name => name != null) .Cast() @@ -124,7 +124,7 @@ internal static (int Major, int Minor, int Patch, bool IsStable, string Pre) Par private void InvalidateCache(string fullPath) { - var docsRootFull = Path.GetFullPath(_options.DocsRoot); + var docsRootFull = Path.GetFullPath(_config.CurrentValue.DocsRoot); var changedFull = Path.GetFullPath(fullPath); if (!changedFull.StartsWith(docsRootFull, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Cocoar.Shelf/Services/ProductConfigService.cs b/src/Cocoar.Shelf/Services/ProductConfigService.cs index 0d3009e..9d78e7c 100644 --- a/src/Cocoar.Shelf/Services/ProductConfigService.cs +++ b/src/Cocoar.Shelf/Services/ProductConfigService.cs @@ -1,8 +1,8 @@ using System.Collections.Concurrent; using System.Text.Json; +using Cocoar.Configuration.Reactive; using Cocoar.FileSystem; using Cocoar.Shelf.Models; -using Microsoft.Extensions.Options; namespace Cocoar.Shelf.Services; @@ -20,10 +20,10 @@ public sealed partial class ProductConfigService : IProductConfigService, IDispo AllowTrailingCommas = true }; - public ProductConfigService(IOptions options, ILogger logger) + public ProductConfigService(IReactiveConfig config, ILogger logger) { _logger = logger; - _productsDir = Path.Combine(options.Value.ConfigRoot, "products"); + _productsDir = Path.Combine(config.CurrentValue.ConfigRoot, "products"); if (!Directory.Exists(_productsDir)) { diff --git a/src/Cocoar.Shelf/Services/UploadService.cs b/src/Cocoar.Shelf/Services/UploadService.cs index 5a8c451..07b0f14 100644 --- a/src/Cocoar.Shelf/Services/UploadService.cs +++ b/src/Cocoar.Shelf/Services/UploadService.cs @@ -1,18 +1,18 @@ using System.Collections.Concurrent; using System.IO.Compression; -using Microsoft.Extensions.Options; +using Cocoar.Configuration.Reactive; namespace Cocoar.Shelf.Services; public sealed partial class UploadService : IUploadService { - private readonly ShelfOptions _options; + private readonly IReactiveConfig _config; private readonly ILogger _logger; private readonly ConcurrentDictionary _locks = new(); - public UploadService(IOptions options, ILogger logger) + public UploadService(IReactiveConfig config, ILogger logger) { - _options = options.Value; + _config = config; _logger = logger; } @@ -24,7 +24,7 @@ public async Task UploadVersionAsync(string product, string versio if (!await semaphore.WaitAsync(0, ct)) return new UploadResult(UploadStatus.VersionConflict, "Upload for this version is already in progress"); - var tempDir = Path.Combine(_options.DocsRoot, ".shelf-tmp", Guid.NewGuid().ToString("N")); + var tempDir = Path.Combine(_config.CurrentValue.DocsRoot, ".shelf-tmp", Guid.NewGuid().ToString("N")); try { @@ -66,15 +66,15 @@ public async Task UploadVersionAsync(string product, string versio return new UploadResult(UploadStatus.MissingIndexHtml, "Archive must contain an index.html at the root"); // Ensure product directory exists - var productDir = Path.Combine(_options.DocsRoot, product); + var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); Directory.CreateDirectory(productDir); // Atomic move: swap existing version if present - var destVersionDir = Path.Combine(_options.DocsRoot, product, version); + var destVersionDir = Path.Combine(_config.CurrentValue.DocsRoot, product, version); if (Directory.Exists(destVersionDir)) { - var oldDir = Path.Combine(_options.DocsRoot, ".shelf-tmp", $"old-{Guid.NewGuid():N}"); + var oldDir = Path.Combine(_config.CurrentValue.DocsRoot, ".shelf-tmp", $"old-{Guid.NewGuid():N}"); Directory.Move(destVersionDir, oldDir); try { Directory.Delete(oldDir, recursive: true); } @@ -88,7 +88,7 @@ public async Task UploadVersionAsync(string product, string versio } catch (Exception ex) when (ex is not OperationCanceledException) { - LogUploadFailed(product, version, ex.Message); + LogUploadFailed(product, version, ex); return new UploadResult(UploadStatus.InvalidArchive, $"Upload failed: {ex.Message}"); } finally @@ -107,6 +107,6 @@ public async Task UploadVersionAsync(string product, string versio [LoggerMessage(Level = LogLevel.Information, Message = "Version deployed: {Product}/{Version}")] private partial void LogVersionDeployed(string product, string version); - [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed for {Product}/{Version}: {Error}")] - private partial void LogUploadFailed(string product, string version, string error); + [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed for {Product}/{Version}")] + private partial void LogUploadFailed(string product, string version, Exception ex); } diff --git a/src/Cocoar.Shelf/appsettings.json b/src/Cocoar.Shelf/appsettings.json index 98d3b93..33724e4 100644 --- a/src/Cocoar.Shelf/appsettings.json +++ b/src/Cocoar.Shelf/appsettings.json @@ -1,9 +1,17 @@ { - "Logging": { - "LogLevel": { + "Serilog": { + "MinimumLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Hosting": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console" + } + ] }, "Shelf": { "DocsRoot": "/data/docs", diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 75c6d81..5d34c4d 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,7 +6,9 @@ + + diff --git a/src/tests/Cocoar.Shelf.Tests/ApiKeyFilterTests.cs b/src/tests/Cocoar.Shelf.Tests/ApiKeyFilterTests.cs index 38625d3..4e43ea1 100644 --- a/src/tests/Cocoar.Shelf.Tests/ApiKeyFilterTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/ApiKeyFilterTests.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging.Abstractions; namespace Cocoar.Shelf.Tests; @@ -55,7 +55,7 @@ public async Task PassesThrough_WhenKeyMatches() private static async Task InvokeFilter(string apiKey, string? authHeader) { var services = new ServiceCollection(); - services.Configure(o => o.ApiKey = apiKey); + services.AddScoped(_ => new ShelfOptions { ApiKey = apiKey }); var httpContext = new DefaultHttpContext { @@ -66,7 +66,7 @@ public async Task PassesThrough_WhenKeyMatches() httpContext.Request.Headers.Authorization = authHeader; var context = new DefaultEndpointFilterInvocationContext(httpContext); - var filter = new ApiKeyFilter(); + var filter = new ApiKeyFilter(NullLogger.Instance); return await filter.InvokeAsync(context, _ => ValueTask.FromResult("passed")); } diff --git a/src/tests/Cocoar.Shelf.Tests/ManifestServiceTests.cs b/src/tests/Cocoar.Shelf.Tests/ManifestServiceTests.cs index aace5da..5b6805e 100644 --- a/src/tests/Cocoar.Shelf.Tests/ManifestServiceTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/ManifestServiceTests.cs @@ -1,6 +1,5 @@ using Cocoar.Shelf.Services; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; namespace Cocoar.Shelf.Tests; @@ -14,8 +13,8 @@ public ManifestServiceTests() _tempDir = Path.Combine(Path.GetTempPath(), $"shelf-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); - var options = Options.Create(new ShelfOptions { DocsRoot = _tempDir }); - _sut = new ManifestService(options, NullLogger.Instance); + var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = _tempDir }); + _sut = new ManifestService(config, NullLogger.Instance); } [Fact] @@ -180,8 +179,8 @@ public void GetProducts_ReturnsProductDirectories() [Fact] public void GetProducts_ReturnsEmpty_WhenNoProducts() { - var options = Options.Create(new ShelfOptions { DocsRoot = Path.Combine(_tempDir, "nonexistent") }); - using var sut = new ManifestService(options, NullLogger.Instance); + var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = Path.Combine(_tempDir, "nonexistent") }); + using var sut = new ManifestService(config, NullLogger.Instance); var products = sut.GetProducts(); diff --git a/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceTests.cs b/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceTests.cs index 30ba2dd..fed5519 100644 --- a/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceTests.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Cocoar.Shelf.Services; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; namespace Cocoar.Shelf.Tests; @@ -19,8 +18,8 @@ public ProductConfigServiceTests() private ProductConfigService CreateService(string? configRoot = null) { - var options = Options.Create(new ShelfOptions { ConfigRoot = configRoot ?? _tempDir }); - return new ProductConfigService(options, NullLogger.Instance); + var config = new TestReactiveConfig(new ShelfOptions { ConfigRoot = configRoot ?? _tempDir }); + return new ProductConfigService(config, NullLogger.Instance); } [Fact] diff --git a/src/tests/Cocoar.Shelf.Tests/TestReactiveConfig.cs b/src/tests/Cocoar.Shelf.Tests/TestReactiveConfig.cs new file mode 100644 index 0000000..4b27306 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/TestReactiveConfig.cs @@ -0,0 +1,19 @@ +using Cocoar.Configuration.Reactive; + +namespace Cocoar.Shelf.Tests; + +internal sealed class TestReactiveConfig(T value) : IReactiveConfig +{ + public T CurrentValue => value; + + public IDisposable Subscribe(IObserver observer) + { + observer.OnNext(value); + return new NoopDisposable(); + } + + private sealed class NoopDisposable : IDisposable + { + public void Dispose() { } + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs b/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs index 328567c..84919c7 100644 --- a/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs @@ -1,7 +1,6 @@ using System.IO.Compression; using Cocoar.Shelf.Services; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; namespace Cocoar.Shelf.Tests; @@ -15,8 +14,8 @@ public UploadServiceTests() _docsRoot = Path.Combine(Path.GetTempPath(), $"shelf-upload-tests-{Guid.NewGuid():N}"); Directory.CreateDirectory(_docsRoot); - var options = Options.Create(new ShelfOptions { DocsRoot = _docsRoot }); - _sut = new UploadService(options, NullLogger.Instance); + var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = _docsRoot }); + _sut = new UploadService(config, NullLogger.Instance); } [Fact] From 1ecd800ba851891b663bd75d8b88586491057aba Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 21 Mar 2026 15:06:29 +0100 Subject: [PATCH 2/8] feat: Add initial setup for Cocoar.Shelf.Client with TypeScript and Vite configuration feat: Implement SPA fallback middleware for handling 404 responses feat: Create models for product requests including creation and update requests feat: Add configuration file for application settings test: Implement integration tests for Admin API including product creation, update, and deletion test: Add integration tests for API products including version retrieval and registration test: Create tests for serving documentation and handling redirects test: Implement tests for upload API including version uploads and validation test: Add tests for upload service delete functionality ensuring proper directory removal test: Create tests for product configuration service ensuring correct file operations --- .github/workflows/cd-deploy-docs.yml | 2 +- .github/workflows/cd-deploy-production.yml | 11 +- .github/workflows/cd-deploy-staging.yml | 9 + .github/workflows/ci-develop.yml | 9 + .github/workflows/ci-pr-validation.yml | 9 + .gitignore | 7 + Dockerfile | 8 + local-config/products/configuration.json | 6 + src/Cocoar.Shelf.Client/index.html | 12 + src/Cocoar.Shelf.Client/package-lock.json | 2364 +++++++++++++++++ src/Cocoar.Shelf.Client/package.json | 28 + src/Cocoar.Shelf.Client/src/App.vue | 8 + .../src/cocoar-modules.d.ts | 21 + .../src/composables/useUI.ts | 86 + src/Cocoar.Shelf.Client/src/core/api/http.ts | 60 + .../src/core/api/shelf-api.ts | 17 + .../src/core/models/shelf.models.ts | 27 + .../src/layouts/AdminLayout.vue | 246 ++ src/Cocoar.Shelf.Client/src/main.ts | 23 + src/Cocoar.Shelf.Client/src/router/index.ts | 39 + .../src/stores/auth.store.ts | 32 + src/Cocoar.Shelf.Client/src/styles.css | 10 + .../src/views/DashboardView.vue | 153 ++ .../src/views/LandingView.vue | 161 ++ .../src/views/LoginView.vue | 107 + .../src/views/products/ProductDetailView.vue | 310 +++ .../src/views/products/ProductFormView.vue | 161 ++ .../src/views/products/ProductListView.vue | 143 + src/Cocoar.Shelf.Client/tsconfig.json | 13 + src/Cocoar.Shelf.Client/vite.config.ts | 31 + src/Cocoar.Shelf/Cocoar.Shelf.csproj | 4 + src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs | 184 +- .../Endpoints/LandingPageEndpoint.cs | 170 -- .../Middleware/DocsRoutingMiddleware.cs | 10 + .../Middleware/SpaFallbackMiddleware.cs | 26 + src/Cocoar.Shelf/Models/ProductRequests.cs | 5 + src/Cocoar.Shelf/Program.cs | 85 +- .../Services/IProductConfigService.cs | 6 + src/Cocoar.Shelf/Services/IUploadService.cs | 2 + .../Services/ProductConfigService.cs | 50 + src/Cocoar.Shelf/Services/UploadService.cs | 27 + src/Cocoar.Shelf/ShelfOptions.cs | 17 +- src/Cocoar.Shelf/appsettings.json | 21 - src/Cocoar.Shelf/data/configuration.json | 14 + src/Directory.Packages.props | 1 + .../Cocoar.Shelf.Tests.csproj | 1 + .../Integration/AdminApiTests.cs | 313 +++ .../Integration/ApiProductsTests.cs | 62 + .../Integration/DocsServingTests.cs | 148 ++ .../Integration/IntegrationTestCollection.cs | 4 + .../Integration/ShelfFixture.cs | 89 + .../Integration/UploadApiTests.cs | 170 ++ .../Integration/UploadDisabledTests.cs | 73 + .../Integration/ZipHelper.cs | 28 + .../ProductConfigServiceWriteTests.cs | 144 + .../UploadServiceDeleteTests.cs | 74 + 56 files changed, 5605 insertions(+), 236 deletions(-) create mode 100644 local-config/products/configuration.json create mode 100644 src/Cocoar.Shelf.Client/index.html create mode 100644 src/Cocoar.Shelf.Client/package-lock.json create mode 100644 src/Cocoar.Shelf.Client/package.json create mode 100644 src/Cocoar.Shelf.Client/src/App.vue create mode 100644 src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts create mode 100644 src/Cocoar.Shelf.Client/src/composables/useUI.ts create mode 100644 src/Cocoar.Shelf.Client/src/core/api/http.ts create mode 100644 src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts create mode 100644 src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts create mode 100644 src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue create mode 100644 src/Cocoar.Shelf.Client/src/main.ts create mode 100644 src/Cocoar.Shelf.Client/src/router/index.ts create mode 100644 src/Cocoar.Shelf.Client/src/stores/auth.store.ts create mode 100644 src/Cocoar.Shelf.Client/src/styles.css create mode 100644 src/Cocoar.Shelf.Client/src/views/DashboardView.vue create mode 100644 src/Cocoar.Shelf.Client/src/views/LandingView.vue create mode 100644 src/Cocoar.Shelf.Client/src/views/LoginView.vue create mode 100644 src/Cocoar.Shelf.Client/src/views/products/ProductDetailView.vue create mode 100644 src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue create mode 100644 src/Cocoar.Shelf.Client/src/views/products/ProductListView.vue create mode 100644 src/Cocoar.Shelf.Client/tsconfig.json create mode 100644 src/Cocoar.Shelf.Client/vite.config.ts delete mode 100644 src/Cocoar.Shelf/Endpoints/LandingPageEndpoint.cs create mode 100644 src/Cocoar.Shelf/Middleware/SpaFallbackMiddleware.cs create mode 100644 src/Cocoar.Shelf/Models/ProductRequests.cs create mode 100644 src/Cocoar.Shelf/data/configuration.json create mode 100644 src/tests/Cocoar.Shelf.Tests/Integration/AdminApiTests.cs create mode 100644 src/tests/Cocoar.Shelf.Tests/Integration/ApiProductsTests.cs create mode 100644 src/tests/Cocoar.Shelf.Tests/Integration/DocsServingTests.cs create mode 100644 src/tests/Cocoar.Shelf.Tests/Integration/IntegrationTestCollection.cs create mode 100644 src/tests/Cocoar.Shelf.Tests/Integration/ShelfFixture.cs create mode 100644 src/tests/Cocoar.Shelf.Tests/Integration/UploadApiTests.cs create mode 100644 src/tests/Cocoar.Shelf.Tests/Integration/UploadDisabledTests.cs create mode 100644 src/tests/Cocoar.Shelf.Tests/Integration/ZipHelper.cs create mode 100644 src/tests/Cocoar.Shelf.Tests/ProductConfigServiceWriteTests.cs create mode 100644 src/tests/Cocoar.Shelf.Tests/UploadServiceDeleteTests.cs diff --git a/.github/workflows/cd-deploy-docs.yml b/.github/workflows/cd-deploy-docs.yml index bf8a1e8..642e6bd 100644 --- a/.github/workflows/cd-deploy-docs.yml +++ b/.github/workflows/cd-deploy-docs.yml @@ -49,7 +49,7 @@ jobs: -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/zip" \ --data-binary @$GITHUB_WORKSPACE/docs.zip \ - "${URL}/api/products/shelf/versions/${VER}" + "${URL}/_api/products/shelf/versions/${VER}" - name: Deployment summary run: | diff --git a/.github/workflows/cd-deploy-production.yml b/.github/workflows/cd-deploy-production.yml index 74e0569..09df33d 100644 --- a/.github/workflows/cd-deploy-production.yml +++ b/.github/workflows/cd-deploy-production.yml @@ -69,6 +69,15 @@ jobs: --password ${{ secrets.PERSONAL_PACKAGES_TOKEN }} \ --store-password-in-clear-text + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build Admin UI + working-directory: ./src/Cocoar.Shelf.Client + run: npm ci && npm run build + - name: Restore dependencies run: dotnet restore Cocoar.Shelf.slnx working-directory: ./src @@ -211,7 +220,7 @@ jobs: -H "Authorization: Bearer $KEY" \ -H "Content-Type: application/zip" \ --data-binary @$GITHUB_WORKSPACE/docs.zip \ - "${URL}/api/products/shelf/versions/${VER}" + "${URL}/_api/products/shelf/versions/${VER}" - name: Docs deployment summary run: | diff --git a/.github/workflows/cd-deploy-staging.yml b/.github/workflows/cd-deploy-staging.yml index b3874b9..010ec8a 100644 --- a/.github/workflows/cd-deploy-staging.yml +++ b/.github/workflows/cd-deploy-staging.yml @@ -75,6 +75,15 @@ jobs: --password ${{ secrets.PERSONAL_PACKAGES_TOKEN }} \ --store-password-in-clear-text + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build Admin UI + working-directory: ./src/Cocoar.Shelf.Client + run: npm ci && npm run build + - name: Restore dependencies run: dotnet restore Cocoar.Shelf.slnx working-directory: ./src diff --git a/.github/workflows/ci-develop.yml b/.github/workflows/ci-develop.yml index f8fc836..e7e12ae 100644 --- a/.github/workflows/ci-develop.yml +++ b/.github/workflows/ci-develop.yml @@ -48,6 +48,15 @@ jobs: - name: Log version run: echo "Building version ${{ steps.gv.outputs.SemVer }}" + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build Admin UI + working-directory: ./src/Cocoar.Shelf.Client + run: npm ci && npm run build + - name: Restore dependencies run: dotnet restore Cocoar.Shelf.slnx working-directory: ./src diff --git a/.github/workflows/ci-pr-validation.yml b/.github/workflows/ci-pr-validation.yml index 381a4ac..7cdce15 100644 --- a/.github/workflows/ci-pr-validation.yml +++ b/.github/workflows/ci-pr-validation.yml @@ -48,6 +48,15 @@ jobs: - name: Log version run: echo "Building version ${{ steps.gv.outputs.SemVer }}" + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build Admin UI + working-directory: ./src/Cocoar.Shelf.Client + run: npm ci && npm run build + - name: Restore dependencies run: dotnet restore Cocoar.Shelf.slnx working-directory: ./src diff --git a/.gitignore b/.gitignore index f5cf5ed..4b358f9 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,13 @@ nul local/ .local/ +# SPA build output (generated by Vite) +src/Cocoar.Shelf/wwwroot/assets/ +src/Cocoar.Shelf/wwwroot/index.html + +# Client dependencies +src/Cocoar.Shelf.Client/node_modules/ + # VitePress website/.vitepress/cache/ website/.vitepress/dist/ diff --git a/Dockerfile b/Dockerfile index c3df8c2..2810563 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,10 @@ +FROM node:22-alpine AS client-build +WORKDIR /client +COPY src/Cocoar.Shelf.Client/package.json src/Cocoar.Shelf.Client/package-lock.json ./ +RUN npm ci +COPY src/Cocoar.Shelf.Client/ . +RUN npx vite build --outDir /client/dist + FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src @@ -5,6 +12,7 @@ COPY src/Cocoar.Shelf/Cocoar.Shelf.csproj Cocoar.Shelf/ RUN dotnet restore Cocoar.Shelf/Cocoar.Shelf.csproj COPY src/ . +COPY --from=client-build /client/dist/ Cocoar.Shelf/wwwroot/ RUN dotnet publish Cocoar.Shelf/Cocoar.Shelf.csproj -c Release -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:10.0 diff --git a/local-config/products/configuration.json b/local-config/products/configuration.json new file mode 100644 index 0000000..873bec9 --- /dev/null +++ b/local-config/products/configuration.json @@ -0,0 +1,6 @@ +{ + "name": "configuration", + "displayName": "Cocoar.Configuration", + "description": "Reactive configuration for .NET", + "source": "upload" +} diff --git a/src/Cocoar.Shelf.Client/index.html b/src/Cocoar.Shelf.Client/index.html new file mode 100644 index 0000000..385325b --- /dev/null +++ b/src/Cocoar.Shelf.Client/index.html @@ -0,0 +1,12 @@ + + + + + + Shelf Admin + + +
+ + + diff --git a/src/Cocoar.Shelf.Client/package-lock.json b/src/Cocoar.Shelf.Client/package-lock.json new file mode 100644 index 0000000..b96019b --- /dev/null +++ b/src/Cocoar.Shelf.Client/package-lock.json @@ -0,0 +1,2364 @@ +{ + "name": "@cocoar/shelf-admin", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@cocoar/shelf-admin", + "version": "0.0.0", + "dependencies": { + "@cocoar/vue-ui": "0.1.0-beta.25", + "overlayscrollbars": "^2.14.0", + "pinia": "^2.3.1", + "vue": "^3.5.28", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.2.0", + "@vitejs/plugin-vue": "^6.0.0", + "@vue/tsconfig": "^0.7.0", + "lucide-static": "^0.577.0", + "tailwindcss": "^4.2.0", + "typescript": "~5.9.0", + "vite": "^7.3.0", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cocoar/vue-fragment-parser": { + "version": "0.1.0-beta.25", + "resolved": "https://registry.npmjs.org/@cocoar/vue-fragment-parser/-/vue-fragment-parser-0.1.0-beta.25.tgz", + "integrity": "sha512-wy1DMw+XiFTbls5KSxdbH0E2bNHHqPWbsCfIdgDLdQDFZxGEwxuPSJQZkrc89s2Jf/+ih2ETWTGG15RTkXx0aA==", + "dependencies": { + "path-to-regexp": "^8.3.0" + } + }, + "node_modules/@cocoar/vue-localization": { + "version": "0.1.0-beta.25", + "resolved": "https://registry.npmjs.org/@cocoar/vue-localization/-/vue-localization-0.1.0-beta.25.tgz", + "integrity": "sha512-OIvrRN+OE3i9Jgu7PcTXp62c/GBuqkJWR0rvBCIT6v8R56JdVIeDoqPJtG7RS0rbXKDoD9TqA0R0BCww5uwQbA==", + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@cocoar/vue-ui": { + "version": "0.1.0-beta.25", + "resolved": "https://registry.npmjs.org/@cocoar/vue-ui/-/vue-ui-0.1.0-beta.25.tgz", + "integrity": "sha512-1nKV6JdnpZTDJ2cHbj5QMQxsmfarF0bvjQiOmOtKTw68ZK7lzldGW9bu+nh2Tc17GqqENlLqT1UjTMaHM3IMPQ==", + "dependencies": { + "@cocoar/vue-fragment-parser": "0.1.0-beta.25", + "@cocoar/vue-localization": "0.1.0-beta.25", + "@js-temporal/polyfill": "^0.5.1", + "@maskito/core": "^5.1.1", + "@maskito/kit": "^5.1.1", + "@maskito/vue": "^5.1.1", + "prismjs": "^1.30.0" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@maskito/core": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@maskito/core/-/core-5.1.2.tgz", + "integrity": "sha512-eoeQ41uDu9AuhFQDzAPTNTr5VM+hMpRsrJjtHzCH3FM7u+/mOGLgtEeGE1+5Up5UCtY7h/N1hPaZ/qT5mcNWXQ==", + "license": "Apache-2.0" + }, + "node_modules/@maskito/kit": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-5.1.2.tgz", + "integrity": "sha512-inVxaa36dLQp1NQ/a5dM791qgDZUulPDs299pS6KNXKN7wrisybSIoRVrpjoZt/QIe2TMtku313sBgtf2LhFAQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@maskito/core": "^5.1.2" + } + }, + "node_modules/@maskito/vue": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@maskito/vue/-/vue-5.1.2.tgz", + "integrity": "sha512-HDkmxeIMWb+Nt/3duDQ+HvndILmA1sBhZT4hc5T+ClbP6k32txStvtAB20NZd/lYoa4J7Nvn666MH5wqJY7bdg==", + "license": "Apache-2.0", + "peerDependencies": { + "@maskito/core": "^5.1.2", + "vue": ">=3.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.1.tgz", + "integrity": "sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.1.tgz", + "integrity": "sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.1.tgz", + "integrity": "sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.1.tgz", + "integrity": "sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.1.tgz", + "integrity": "sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.1.tgz", + "integrity": "sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.1.tgz", + "integrity": "sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.1.tgz", + "integrity": "sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.1.tgz", + "integrity": "sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.1.tgz", + "integrity": "sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.1.tgz", + "integrity": "sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.1.tgz", + "integrity": "sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.1.tgz", + "integrity": "sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.1.tgz", + "integrity": "sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.1.tgz", + "integrity": "sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.1.tgz", + "integrity": "sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.1.tgz", + "integrity": "sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.1.tgz", + "integrity": "sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.1.tgz", + "integrity": "sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.1.tgz", + "integrity": "sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.1.tgz", + "integrity": "sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.1.tgz", + "integrity": "sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.1.tgz", + "integrity": "sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.1.tgz", + "integrity": "sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.1.tgz", + "integrity": "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", + "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "postcss": "^8.5.6", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.7.0.tgz", + "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-static": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-0.577.0.tgz", + "integrity": "sha512-hx39J5Tq4JWF2ALY+5YRg+SxQLpeAmLJDXNcqiBJH/UuVwp43it9fyki/onZO7AVFgG5ZbB+fWwZR9mwGHE2XQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/overlayscrollbars": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.14.0.tgz", + "integrity": "sha512-RjV0pqc79kYhQLC3vTcLRb5GLpI1n6qh0Oua3g+bGH4EgNOJHVBGP7u0zZtxoAa0dkHlAqTTSYRb9MMmxNLjig==", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rollup": { + "version": "4.59.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.1.tgz", + "integrity": "sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.1", + "@rollup/rollup-android-arm64": "4.59.1", + "@rollup/rollup-darwin-arm64": "4.59.1", + "@rollup/rollup-darwin-x64": "4.59.1", + "@rollup/rollup-freebsd-arm64": "4.59.1", + "@rollup/rollup-freebsd-x64": "4.59.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.1", + "@rollup/rollup-linux-arm-musleabihf": "4.59.1", + "@rollup/rollup-linux-arm64-gnu": "4.59.1", + "@rollup/rollup-linux-arm64-musl": "4.59.1", + "@rollup/rollup-linux-loong64-gnu": "4.59.1", + "@rollup/rollup-linux-loong64-musl": "4.59.1", + "@rollup/rollup-linux-ppc64-gnu": "4.59.1", + "@rollup/rollup-linux-ppc64-musl": "4.59.1", + "@rollup/rollup-linux-riscv64-gnu": "4.59.1", + "@rollup/rollup-linux-riscv64-musl": "4.59.1", + "@rollup/rollup-linux-s390x-gnu": "4.59.1", + "@rollup/rollup-linux-x64-gnu": "4.59.1", + "@rollup/rollup-linux-x64-musl": "4.59.1", + "@rollup/rollup-openbsd-x64": "4.59.1", + "@rollup/rollup-openharmony-arm64": "4.59.1", + "@rollup/rollup-win32-arm64-msvc": "4.59.1", + "@rollup/rollup-win32-ia32-msvc": "4.59.1", + "@rollup/rollup-win32-x64-gnu": "4.59.1", + "@rollup/rollup-win32-x64-msvc": "4.59.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/src/Cocoar.Shelf.Client/package.json b/src/Cocoar.Shelf.Client/package.json new file mode 100644 index 0000000..fd355fb --- /dev/null +++ b/src/Cocoar.Shelf.Client/package.json @@ -0,0 +1,28 @@ +{ + "name": "@cocoar/shelf-admin", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@cocoar/vue-ui": "0.1.0-beta.25", + "overlayscrollbars": "^2.14.0", + "pinia": "^2.3.1", + "vue": "^3.5.28", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.2.0", + "@vitejs/plugin-vue": "^6.0.0", + "@vue/tsconfig": "^0.7.0", + "lucide-static": "^0.577.0", + "tailwindcss": "^4.2.0", + "typescript": "~5.9.0", + "vite": "^7.3.0", + "vue-tsc": "^2.2.0" + } +} diff --git a/src/Cocoar.Shelf.Client/src/App.vue b/src/Cocoar.Shelf.Client/src/App.vue new file mode 100644 index 0000000..68eef6f --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/App.vue @@ -0,0 +1,8 @@ + + + diff --git a/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts b/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts new file mode 100644 index 0000000..8ab412f --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts @@ -0,0 +1,21 @@ +declare module '@cocoar/vue-ui' { + import type { Plugin, Component } from 'vue'; + + export const CoarIconPlugin: Plugin; + export const CoarOverlayPlugin: Plugin; + export const CORE_ICONS: unknown; + + export class CoarHttpIconSource { + constructor(resolver: (name: string) => string); + } + + export const CoarButton: Component; + export const CoarCard: Component; + export const CoarCheckbox: Component; + export const CoarIcon: Component; + export const CoarNote: Component; + export const CoarOverlayHost: Component; + export const CoarTextInput: Component; +} + +declare module '@cocoar/vue-ui/styles' {} diff --git a/src/Cocoar.Shelf.Client/src/composables/useUI.ts b/src/Cocoar.Shelf.Client/src/composables/useUI.ts new file mode 100644 index 0000000..6bf40ac --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/composables/useUI.ts @@ -0,0 +1,86 @@ +import { reactive } from 'vue'; + +export interface UIButton { + text?: string; + disabled?: boolean; + loading?: boolean; + visible?: boolean; + onClick?: () => void; +} + +export interface UIHeader { + show: boolean; + title?: string; + subTitle?: string; + icon?: string; +} + +export interface UIContent { + scrollable: boolean; + showLoadingBar: boolean; + container: boolean; + padding: boolean; +} + +export interface UIFooter { + show: boolean; + button1: UIButton; + button2: UIButton; + button3: UIButton; +} + +export interface UIContext { + header: UIHeader; + content: UIContent; + footer: UIFooter; +} + +function createDefaults(): UIContext { + return { + header: { + show: true, + title: undefined, + subTitle: undefined, + icon: undefined, + }, + content: { + scrollable: true, + showLoadingBar: false, + container: true, + padding: true, + }, + footer: { + show: false, + button1: { visible: false, disabled: false, loading: false }, + button2: { visible: false, disabled: false, loading: false }, + button3: { visible: false, disabled: false, loading: false }, + }, + }; +} + +const state = reactive(createDefaults()); + +export function useUI() { + function set(fn: (ctx: UIContext) => void) { + const defaults = createDefaults(); + Object.assign(state.header, defaults.header); + Object.assign(state.content, defaults.content); + Object.assign(state.footer.button1, defaults.footer.button1); + Object.assign(state.footer.button2, defaults.footer.button2); + Object.assign(state.footer.button3, defaults.footer.button3); + state.footer.show = defaults.footer.show; + fn(state); + } + + function reset() { + const defaults = createDefaults(); + Object.assign(state.header, defaults.header); + Object.assign(state.content, defaults.content); + Object.assign(state.footer.button1, defaults.footer.button1); + Object.assign(state.footer.button2, defaults.footer.button2); + Object.assign(state.footer.button3, defaults.footer.button3); + state.footer.show = defaults.footer.show; + } + + return { state, set, reset }; +} diff --git a/src/Cocoar.Shelf.Client/src/core/api/http.ts b/src/Cocoar.Shelf.Client/src/core/api/http.ts new file mode 100644 index 0000000..c38d476 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/core/api/http.ts @@ -0,0 +1,60 @@ +import { useAuthStore } from '@/stores/auth.store'; +import { router } from '@/router'; + +export class ApiError extends Error { + constructor(public readonly status: number, public readonly body: unknown) { + const message = (body as { error?: string })?.error ?? `HTTP ${status}`; + super(message); + this.name = 'ApiError'; + } +} + +async function request(path: string, init: RequestInit = {}): Promise { + const headers: Record = { + ...(init.headers as Record), + }; + + const auth = useAuthStore(); + if (auth.apiKey) { + headers['Authorization'] = `Bearer ${auth.apiKey}`; + } + + if (init.body && typeof init.body === 'string') { + headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(`/_api${path}`, { ...init, headers }); + + if (!response.ok) { + if (response.status === 401) { + auth.logout(); + router.push('/login'); + } + const contentType = response.headers.get('content-type') ?? ''; + const errData = contentType.includes('application/json') + ? await response.json().catch(() => null) + : await response.text().catch(() => null); + throw new ApiError(response.status, errData); + } + + if (response.status === 204 || response.headers.get('content-length') === '0') { + return undefined as T; + } + + return await response.json() as T; +} + +export const http = { + get: (path: string) => request(path, { method: 'GET' }), + post: (path: string, body?: unknown) => + request(path, { method: 'POST', body: body !== undefined ? JSON.stringify(body) : undefined }), + put: (path: string, body?: unknown) => + request(path, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }), + delete: (path: string) => request(path, { method: 'DELETE' }), + upload: (path: string, file: File | Blob) => { + const headers: Record = { 'Content-Type': 'application/zip' }; + const auth = useAuthStore(); + if (auth.apiKey) headers['Authorization'] = `Bearer ${auth.apiKey}`; + return request(path, { method: 'POST', body: file, headers }); + }, +}; diff --git a/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts b/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts new file mode 100644 index 0000000..dc83b90 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts @@ -0,0 +1,17 @@ +import { http } from './http'; +import type { Product, ProductVersions, CreateProductRequest, UpdateProductRequest } from '../models/shelf.models'; + +export const shelfApi = { + verifyApiKey: () => http.get<{ ok: boolean }>('/admin/verify'), + + getProducts: () => http.get('/products'), + getVersions: (product: string) => http.get(`/products/${product}/versions`), + createProduct: (req: CreateProductRequest) => http.post('/products', req), + updateProduct: (name: string, req: UpdateProductRequest) => http.put(`/products/${name}`, req), + deleteProduct: (name: string) => http.delete(`/products/${name}`), + + deleteVersion: (product: string, version: string) => + http.delete(`/products/${product}/versions/${version}`), + uploadVersion: (product: string, version: string, file: File | Blob) => + http.upload(`/products/${product}/versions/${version}`, file), +}; diff --git a/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts new file mode 100644 index 0000000..1645959 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts @@ -0,0 +1,27 @@ +export interface Product { + name: string; + displayName: string | null; + description: string | null; + source: string; + latest: string | null; + versions: string[]; +} + +export interface ProductVersions { + name: string; + latest: string | null; + versions: string[]; +} + +export interface CreateProductRequest { + name: string; + displayName?: string; + description?: string; + source?: string; +} + +export interface UpdateProductRequest { + displayName?: string; + description?: string; + source?: string; +} diff --git a/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue b/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..7e04a97 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/main.ts b/src/Cocoar.Shelf.Client/src/main.ts new file mode 100644 index 0000000..8e4f882 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/main.ts @@ -0,0 +1,23 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import { CoarIconPlugin, CoarOverlayPlugin, CoarHttpIconSource, CORE_ICONS } from '@cocoar/vue-ui'; +import App from './App.vue'; +import { router } from './router'; +import '@cocoar/vue-ui/styles'; +import './styles.css'; + +const app = createApp(App); +app.use(createPinia()); +app.use(router); +app.use(CoarIconPlugin, { + sources: [ + CORE_ICONS, + { + key: 'lucide', + source: new CoarHttpIconSource((name) => `/icons/lucide/${name}.svg`), + }, + ], + defaultSource: 'lucide', +}); +app.use(CoarOverlayPlugin); +app.mount('#app'); diff --git a/src/Cocoar.Shelf.Client/src/router/index.ts b/src/Cocoar.Shelf.Client/src/router/index.ts new file mode 100644 index 0000000..eb40022 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/router/index.ts @@ -0,0 +1,39 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import { useAuthStore } from '@/stores/auth.store'; + +export const router = createRouter({ + history: createWebHistory('/'), + routes: [ + { + path: '/', + component: () => import('@/views/LandingView.vue'), + meta: { public: true }, + }, + { + path: '/login', + component: () => import('@/views/LoginView.vue'), + meta: { public: true }, + }, + { + path: '/admin', + component: () => import('@/layouts/AdminLayout.vue'), + children: [ + { path: '', component: () => import('@/views/DashboardView.vue') }, + { path: 'products', component: () => import('@/views/products/ProductListView.vue') }, + { path: 'products/create', component: () => import('@/views/products/ProductFormView.vue') }, + { path: 'products/:name', component: () => import('@/views/products/ProductDetailView.vue') }, + { path: 'products/:name/edit', component: () => import('@/views/products/ProductFormView.vue') }, + ], + }, + ], +}); + +router.beforeEach((to) => { + const auth = useAuthStore(); + if (!to.meta.public && !auth.isAuthenticated) { + return '/login'; + } + if (to.path === '/login' && auth.isAuthenticated) { + return '/admin'; + } +}); diff --git a/src/Cocoar.Shelf.Client/src/stores/auth.store.ts b/src/Cocoar.Shelf.Client/src/stores/auth.store.ts new file mode 100644 index 0000000..0c1b642 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/stores/auth.store.ts @@ -0,0 +1,32 @@ +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; + +export const useAuthStore = defineStore('auth', () => { + const apiKey = ref(sessionStorage.getItem('shelf_api_key')); + const isAuthenticated = computed(() => !!apiKey.value); + + async function login(key: string): Promise { + apiKey.value = key; + try { + const response = await fetch('/_api/admin/verify', { + headers: { 'Authorization': `Bearer ${key}` }, + }); + if (response.ok) { + sessionStorage.setItem('shelf_api_key', key); + return true; + } + apiKey.value = null; + return false; + } catch { + apiKey.value = null; + return false; + } + } + + function logout() { + apiKey.value = null; + sessionStorage.removeItem('shelf_api_key'); + } + + return { apiKey, isAuthenticated, login, logout }; +}); diff --git a/src/Cocoar.Shelf.Client/src/styles.css b/src/Cocoar.Shelf.Client/src/styles.css new file mode 100644 index 0000000..a2e723d --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/styles.css @@ -0,0 +1,10 @@ +@import "tailwindcss"; + +html, body, #app { + height: 100%; + margin: 0; +} + +body { + background-color: var(--coar-background-neutral-secondary); +} diff --git a/src/Cocoar.Shelf.Client/src/views/DashboardView.vue b/src/Cocoar.Shelf.Client/src/views/DashboardView.vue new file mode 100644 index 0000000..e82898b --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/DashboardView.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/LandingView.vue b/src/Cocoar.Shelf.Client/src/views/LandingView.vue new file mode 100644 index 0000000..9a0eb18 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/LandingView.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/LoginView.vue b/src/Cocoar.Shelf.Client/src/views/LoginView.vue new file mode 100644 index 0000000..d7200a6 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/LoginView.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductDetailView.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductDetailView.vue new file mode 100644 index 0000000..cc86c6d --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductDetailView.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue new file mode 100644 index 0000000..44ed734 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductListView.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductListView.vue new file mode 100644 index 0000000..984d0fa --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductListView.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/src/Cocoar.Shelf.Client/tsconfig.json b/src/Cocoar.Shelf.Client/tsconfig.json new file mode 100644 index 0000000..aa7faa7 --- /dev/null +++ b/src/Cocoar.Shelf.Client/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["src/**/*"], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false + } +} diff --git a/src/Cocoar.Shelf.Client/vite.config.ts b/src/Cocoar.Shelf.Client/vite.config.ts new file mode 100644 index 0000000..0bb22fb --- /dev/null +++ b/src/Cocoar.Shelf.Client/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import tailwindcss from '@tailwindcss/postcss'; + +export default defineConfig({ + base: '/', + plugins: [vue()], + css: { + postcss: { + plugins: [tailwindcss()], + }, + }, + build: { + outDir: '../Cocoar.Shelf/wwwroot', + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + '/_api': { + target: 'http://localhost:5200', + changeOrigin: true, + }, + }, + }, + resolve: { + alias: { + '@': '/src', + }, + }, +}); diff --git a/src/Cocoar.Shelf/Cocoar.Shelf.csproj b/src/Cocoar.Shelf/Cocoar.Shelf.csproj index 4822782..83366c4 100644 --- a/src/Cocoar.Shelf/Cocoar.Shelf.csproj +++ b/src/Cocoar.Shelf/Cocoar.Shelf.csproj @@ -10,4 +10,8 @@
+ + + + diff --git a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs index 1057c85..4d41f37 100644 --- a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs +++ b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using Cocoar.Shelf.Models; using Cocoar.Shelf.Services; using Microsoft.AspNetCore.Http.Features; @@ -6,14 +7,30 @@ namespace Cocoar.Shelf.Endpoints; public static partial class ApiEndpoints { + private static readonly Regex ProductNameRegex = new("^[a-z0-9][a-z0-9-]*$", RegexOptions.Compiled); + private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) { "admin", "api" }; + public static WebApplication MapApiEndpoints(this WebApplication app) { - var api = app.MapGroup("/api"); + var api = app.MapGroup("/_api"); + // Public read endpoints api.MapGet("/products", GetProducts); api.MapGet("/products/{product}/versions", GetVersions); + + // Protected write endpoints + api.MapGet("/admin/verify", () => Results.Ok(new { ok = true })) + .AddEndpointFilter(); + api.MapPost("/products", CreateProduct) + .AddEndpointFilter(); + api.MapPut("/products/{product}", UpdateProduct) + .AddEndpointFilter(); + api.MapDelete("/products/{product}", DeleteProduct) + .AddEndpointFilter(); api.MapPost("/products/{product}/versions/{version}", UploadVersion) .AddEndpointFilter(); + api.MapDelete("/products/{product}/versions/{version}", DeleteVersion) + .AddEndpointFilter(); return app; } @@ -184,7 +201,7 @@ private static async Task UploadVersionCore( switch (result.Status) { case UploadStatus.Success: - return Results.Created($"{httpContext.Request.PathBase}/api/products/{product}/versions/{version}", null); + return Results.Created($"{httpContext.Request.PathBase}/_api/products/{product}/versions/{version}", null); case UploadStatus.MissingIndexHtml: LogUploadRejected(logger, product, version, "missing index.html"); @@ -204,6 +221,151 @@ private static async Task UploadVersionCore( } } + private static async Task CreateProduct( + CreateProductRequest request, + IProductConfigService configService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + if (string.IsNullOrWhiteSpace(request.Name)) + return Results.Json(new { error = "Product name is required" }, statusCode: 400); + + if (!ProductNameRegex.IsMatch(request.Name)) + return Results.Json(new { error = "Product name must contain only lowercase letters, numbers, and hyphens" }, statusCode: 400); + + if (ReservedNames.Contains(request.Name)) + return Results.Json(new { error = $"'{request.Name}' is a reserved name" }, statusCode: 400); + + if (configService.GetConfig(request.Name) != null) + { + LogProductAlreadyExists(logger, request.Name); + return Results.Json(new { error = $"Product '{request.Name}' already exists" }, statusCode: 409); + } + + var config = new ProductConfig + { + Name = request.Name, + DisplayName = request.DisplayName, + Description = request.Description, + Source = request.Source ?? "upload" + }; + + await configService.CreateAsync(config); + LogProductCreated(logger, request.Name); + return Results.Created($"/_api/products/{request.Name}", config); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "create", request.Name, ex); + return Results.Json(new { error = $"Failed to create product: {ex.Message}" }, statusCode: 500); + } + } + + private static async Task UpdateProduct( + string product, + UpdateProductRequest request, + IProductConfigService configService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var existing = configService.GetConfig(product); + if (existing == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + var config = new ProductConfig + { + Name = product, + DisplayName = request.DisplayName ?? existing.DisplayName, + Description = request.Description ?? existing.Description, + Source = request.Source ?? existing.Source + }; + + await configService.UpdateAsync(config); + LogProductUpdated(logger, product); + return Results.Ok(config); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "update", product, ex); + return Results.Json(new { error = $"Failed to update product: {ex.Message}" }, statusCode: 500); + } + } + + private static async Task DeleteProduct( + string product, + IProductConfigService configService, + IUploadService uploadService, + ILoggerFactory loggerFactory, + bool deleteData = false) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var existing = configService.GetConfig(product); + if (existing == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + await configService.DeleteAsync(product); + LogProductDeleted(logger, product, deleteData); + + return Results.NoContent(); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "delete", product, ex); + return Results.Json(new { error = $"Failed to delete product: {ex.Message}" }, statusCode: 500); + } + } + + private static async Task DeleteVersion( + string product, + string version, + IProductConfigService configService, + IUploadService uploadService, + ShelfOptions options, + ILoggerFactory loggerFactory, + CancellationToken ct) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var config = configService.GetConfig(product); + if (config == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + if (!Regex.IsMatch(version, options.VersionPattern)) + { + LogUploadInvalidVersion(logger, version, product, options.VersionPattern); + return Results.Json(new { error = $"Invalid version format: '{version}'" }, statusCode: 400); + } + + var deleted = await uploadService.DeleteVersionAsync(product, version, ct); + if (!deleted) + return Results.Json(new { error = $"Version '{version}' not found for product '{product}'" }, statusCode: 404); + + LogVersionDeleted(logger, product, version); + return Results.NoContent(); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "delete version", $"{product}/{version}", ex); + return Results.Json(new { error = $"Failed to delete version: {ex.Message}" }, statusCode: 500); + } + } + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to list products")] private static partial void LogListProductsFailed(ILogger logger, Exception ex); @@ -245,4 +407,22 @@ private static async Task UploadVersionCore( [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed with unexpected status {Status} for {Product}/{Version}")] private static partial void LogUploadUnexpectedStatus(ILogger logger, string product, string version, UploadStatus status); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Product already exists: {Product}")] + private static partial void LogProductAlreadyExists(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Information, Message = "Product created: {Product}")] + private static partial void LogProductCreated(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Information, Message = "Product updated: {Product}")] + private static partial void LogProductUpdated(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Information, Message = "Product deleted: {Product} (deleteData={DeleteData})")] + private static partial void LogProductDeleted(ILogger logger, string product, bool deleteData); + + [LoggerMessage(Level = LogLevel.Information, Message = "Version deleted: {Product}/{Version}")] + private static partial void LogVersionDeleted(ILogger logger, string product, string version); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to {Operation} product {Product}")] + private static partial void LogProductOperationFailed(ILogger logger, string operation, string product, Exception ex); } diff --git a/src/Cocoar.Shelf/Endpoints/LandingPageEndpoint.cs b/src/Cocoar.Shelf/Endpoints/LandingPageEndpoint.cs deleted file mode 100644 index c4857a0..0000000 --- a/src/Cocoar.Shelf/Endpoints/LandingPageEndpoint.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System.Globalization; -using System.Net; -using System.Text; -using Cocoar.Shelf.Services; - -namespace Cocoar.Shelf.Endpoints; - -public static class LandingPageEndpoint -{ - public static IResult Render( - HttpContext httpContext, - IProductConfigService configService, - IManifestService manifestService) - { - var pathBase = httpContext.Request.PathBase.Value ?? ""; - var products = configService.GetAll(); // sorted by name - - var cards = new StringBuilder(); - - foreach (var config in products) - { - var manifest = manifestService.GetManifest(config.Name); - if (manifest == null) continue; - - var latestUrl = $"{pathBase}/{config.Name}/"; - - var badges = new StringBuilder(); - foreach (var version in manifest.Versions) - { - var versionUrl = $"{pathBase}/{config.Name}/{version}/"; - var isLatest = version == manifest.Latest; - var cssClass = isLatest ? "version-badge latest" : "version-badge"; - badges.Append(CultureInfo.InvariantCulture, - $"""{Encode(version)}"""); - } - - cards.AppendLine(CultureInfo.InvariantCulture, $""" - - """); - } - - var html = Template.Replace("{cards}", cards.ToString()); - - return Results.Content(html, "text/html; charset=utf-8"); - } - - private static string Encode(string value) => WebUtility.HtmlEncode(value); - - private const string Template = """ - - - - - - Documentation - - - -
-

Documentation

-
-
{cards}
- - - """; -} diff --git a/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs b/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs index 7083662..9cebdc7 100644 --- a/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs +++ b/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs @@ -43,6 +43,13 @@ public async Task InvokeAsync(HttpContext context) return; } + // Reserved prefixes — never interpret as product names + if (path.StartsWith('_')) + { + await _next(context); + return; + } + var segments = path.Split('/', 2); var product = segments[0]; var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); @@ -53,6 +60,9 @@ public async Task InvokeAsync(HttpContext context) return; } + // Mark this request as handled by docs routing (prevents SPA fallback) + context.Items["DocsRouted"] = true; + var rest = segments.Length > 1 ? segments[1] : ""; string resolvedPath; string version; diff --git a/src/Cocoar.Shelf/Middleware/SpaFallbackMiddleware.cs b/src/Cocoar.Shelf/Middleware/SpaFallbackMiddleware.cs new file mode 100644 index 0000000..5ace0fb --- /dev/null +++ b/src/Cocoar.Shelf/Middleware/SpaFallbackMiddleware.cs @@ -0,0 +1,26 @@ +namespace Cocoar.Shelf.Middleware; + +public class SpaFallbackMiddleware(RequestDelegate next, IWebHostEnvironment env) +{ + public async Task InvokeAsync(HttpContext context) + { + await next(context); + + // Serve the SPA index.html for unhandled GET/HEAD requests that returned 404. + // Skip if the request was already handled by docs routing (real 404 for missing docs files). + if (!context.Response.HasStarted && + context.Response.StatusCode == 404 && + !context.Items.ContainsKey("DocsRouted") && + (context.Request.Method == HttpMethods.Get || context.Request.Method == HttpMethods.Head)) + { + var indexPath = Path.Combine(env.WebRootPath ?? "", "index.html"); + if (File.Exists(indexPath)) + { + context.Response.StatusCode = 200; + context.Response.ContentType = "text/html"; + context.Response.Headers.CacheControl = "no-cache"; + await context.Response.SendFileAsync(indexPath); + } + } + } +} diff --git a/src/Cocoar.Shelf/Models/ProductRequests.cs b/src/Cocoar.Shelf/Models/ProductRequests.cs new file mode 100644 index 0000000..194a2f8 --- /dev/null +++ b/src/Cocoar.Shelf/Models/ProductRequests.cs @@ -0,0 +1,5 @@ +namespace Cocoar.Shelf.Models; + +public record CreateProductRequest(string Name, string? DisplayName, string? Description, string? Source); + +public record UpdateProductRequest(string? DisplayName, string? Description, string? Source); diff --git a/src/Cocoar.Shelf/Program.cs b/src/Cocoar.Shelf/Program.cs index db71e31..25edefc 100644 --- a/src/Cocoar.Shelf/Program.cs +++ b/src/Cocoar.Shelf/Program.cs @@ -1,56 +1,63 @@ using Cocoar.Configuration.AspNetCore; +using Cocoar.Configuration.DI.Extensions; using Cocoar.Configuration.Providers; -using Cocoar.Configuration.Reactive; using Cocoar.Shelf; using Cocoar.Shelf.Endpoints; using Cocoar.Shelf.Middleware; using Cocoar.Shelf.Services; using System.Globalization; using Serilog; +using Serilog.Sinks.SystemConsole.Themes; Log.Logger = new LoggerConfiguration() .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) .CreateBootstrapLogger(); -try -{ - var builder = WebApplication.CreateBuilder(args); - - builder.Host.UseSerilog((context, config) => config - .ReadFrom.Configuration(context.Configuration)); - - builder.AddCocoarConfiguration(c => c - .UseConfiguration(rules => [ - rules.For().FromFile("appsettings.json").Select("Shelf"), - rules.For().FromEnvironment("Shelf__") - ])); - - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); +var builder = WebApplication.CreateBuilder(args); - var app = builder.Build(); +builder.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromFile("data/configuration.json"), + rules.For().FromEnvironment("Shelf__"), + ])); - var shelfOptions = app.Services.GetRequiredService>().CurrentValue; - if (!string.IsNullOrEmpty(shelfOptions.PathBase)) - app.UsePathBase(shelfOptions.PathBase); +var configManager = builder.GetCocoarConfigManager(); +var config = configManager.GetConfig(); - if (shelfOptions.EnableLandingPage) - app.MapGet("/", LandingPageEndpoint.Render); - - app.UseSerilogRequestLogging(); - - app.MapApiEndpoints(); - app.UseMiddleware(); - - app.Run(); -} -catch (Exception ex) when (ex is not HostAbortedException) -{ - Log.Fatal(ex, "Application terminated unexpectedly"); -} -finally +builder.Services.AddSerilog(logConfig => { - Log.CloseAndFlush(); -} + foreach (var (key, level) in config.Logging.LogLevels) + { + if (key.Equals("default", StringComparison.OrdinalIgnoreCase) || + key.Equals("*", StringComparison.OrdinalIgnoreCase)) + { + logConfig.MinimumLevel.Is(level); + } + else + { + logConfig.MinimumLevel.Override(key, level); + } + } + + logConfig.WriteTo.Console(theme: AnsiConsoleTheme.Code); +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +if (!string.IsNullOrEmpty(config.PathBase)) + app.UsePathBase(config.PathBase); + +app.UseMiddleware(); +app.UseSerilogRequestLogging(); +app.UseStaticFiles(); +app.MapApiEndpoints(); +app.UseMiddleware(); + +app.Run(config.AppUrl); + +public partial class Program; diff --git a/src/Cocoar.Shelf/Services/IProductConfigService.cs b/src/Cocoar.Shelf/Services/IProductConfigService.cs index 4606ab0..43ed5a4 100644 --- a/src/Cocoar.Shelf/Services/IProductConfigService.cs +++ b/src/Cocoar.Shelf/Services/IProductConfigService.cs @@ -7,4 +7,10 @@ public interface IProductConfigService ProductConfig? GetConfig(string name); IReadOnlyList GetAll(); + + Task CreateAsync(ProductConfig config); + + Task UpdateAsync(ProductConfig config); + + Task DeleteAsync(string name); } diff --git a/src/Cocoar.Shelf/Services/IUploadService.cs b/src/Cocoar.Shelf/Services/IUploadService.cs index 3ea0ca3..f43d395 100644 --- a/src/Cocoar.Shelf/Services/IUploadService.cs +++ b/src/Cocoar.Shelf/Services/IUploadService.cs @@ -3,6 +3,8 @@ namespace Cocoar.Shelf.Services; public interface IUploadService { Task UploadVersionAsync(string product, string version, Stream zipStream, CancellationToken ct = default); + + Task DeleteVersionAsync(string product, string version, CancellationToken ct = default); } public enum UploadStatus diff --git a/src/Cocoar.Shelf/Services/ProductConfigService.cs b/src/Cocoar.Shelf/Services/ProductConfigService.cs index 9d78e7c..812c77d 100644 --- a/src/Cocoar.Shelf/Services/ProductConfigService.cs +++ b/src/Cocoar.Shelf/Services/ProductConfigService.cs @@ -122,6 +122,56 @@ private void TryLoadFile(string fullPath) } } + private static readonly JsonSerializerOptions WriteJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public async Task CreateAsync(ProductConfig config) + { + ArgumentException.ThrowIfNullOrWhiteSpace(config.Name); + + Directory.CreateDirectory(_productsDir); + + var filePath = Path.Combine(_productsDir, $"{config.Name}.json"); + if (File.Exists(filePath)) + throw new InvalidOperationException($"Product '{config.Name}' already exists"); + + var json = JsonSerializer.Serialize(config, WriteJsonOptions); + await File.WriteAllTextAsync(filePath, json); + _cache[config.Name] = config; + LogConfigLoaded(config.Name); + } + + public async Task UpdateAsync(ProductConfig config) + { + ArgumentException.ThrowIfNullOrWhiteSpace(config.Name); + + var filePath = Path.Combine(_productsDir, $"{config.Name}.json"); + if (!File.Exists(filePath)) + throw new KeyNotFoundException($"Product '{config.Name}' not found"); + + var json = JsonSerializer.Serialize(config, WriteJsonOptions); + await File.WriteAllTextAsync(filePath, json); + _cache[config.Name] = config; + LogConfigLoaded(config.Name); + } + + public Task DeleteAsync(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var filePath = Path.Combine(_productsDir, $"{name}.json"); + if (!File.Exists(filePath)) + return Task.FromResult(false); + + File.Delete(filePath); + _cache.TryRemove(name, out _); + LogConfigRemoved(name); + return Task.FromResult(true); + } + public void Dispose() { _monitor?.Dispose(); diff --git a/src/Cocoar.Shelf/Services/UploadService.cs b/src/Cocoar.Shelf/Services/UploadService.cs index 07b0f14..d09d855 100644 --- a/src/Cocoar.Shelf/Services/UploadService.cs +++ b/src/Cocoar.Shelf/Services/UploadService.cs @@ -104,9 +104,36 @@ public async Task UploadVersionAsync(string product, string versio } } + public async Task DeleteVersionAsync(string product, string version, CancellationToken ct = default) + { + var key = $"{product}/{version}"; + var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + + if (!await semaphore.WaitAsync(0, ct)) + return false; + + try + { + var versionDir = Path.Combine(_config.CurrentValue.DocsRoot, product, version); + if (!Directory.Exists(versionDir)) + return false; + + Directory.Delete(versionDir, recursive: true); + LogVersionDeleted(product, version); + return true; + } + finally + { + semaphore.Release(); + } + } + [LoggerMessage(Level = LogLevel.Information, Message = "Version deployed: {Product}/{Version}")] private partial void LogVersionDeployed(string product, string version); + [LoggerMessage(Level = LogLevel.Information, Message = "Version deleted: {Product}/{Version}")] + private partial void LogVersionDeleted(string product, string version); + [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed for {Product}/{Version}")] private partial void LogUploadFailed(string product, string version, Exception ex); } diff --git a/src/Cocoar.Shelf/ShelfOptions.cs b/src/Cocoar.Shelf/ShelfOptions.cs index 81d2b51..f47ac45 100644 --- a/src/Cocoar.Shelf/ShelfOptions.cs +++ b/src/Cocoar.Shelf/ShelfOptions.cs @@ -1,7 +1,11 @@ +using Serilog.Events; + namespace Cocoar.Shelf; public class ShelfOptions { + public string AppUrl { get; set; } = "http://0.0.0.0:8080"; + public string DocsRoot { get; set; } = "/data/docs"; public string ConfigRoot { get; set; } = "/data/config"; @@ -12,9 +16,18 @@ public class ShelfOptions public string BasePlaceholder { get; set; } = "/__shelf__/"; - public bool EnableLandingPage { get; set; } - public string ApiKey { get; set; } = ""; public long MaxUploadSizeBytes { get; set; } = 104_857_600; // 100 MB + + public ShelfLogging Logging { get; set; } = new(); +} + +public class ShelfLogging +{ + public Dictionary LogLevels { get; set; } = new() + { + ["Default"] = LogEventLevel.Information, + ["Microsoft.AspNetCore"] = LogEventLevel.Warning, + }; } diff --git a/src/Cocoar.Shelf/appsettings.json b/src/Cocoar.Shelf/appsettings.json index 33724e4..2c63c08 100644 --- a/src/Cocoar.Shelf/appsettings.json +++ b/src/Cocoar.Shelf/appsettings.json @@ -1,23 +1,2 @@ { - "Serilog": { - "MinimumLevel": { - "Default": "Information", - "Override": { - "Microsoft.AspNetCore": "Warning", - "Microsoft.AspNetCore.Hosting": "Information" - } - }, - "WriteTo": [ - { - "Name": "Console" - } - ] - }, - "Shelf": { - "DocsRoot": "/data/docs", - "ConfigRoot": "/data/config", - "ApiKey": "", - "PathBase": "", - "EnableLandingPage": false - } } diff --git a/src/Cocoar.Shelf/data/configuration.json b/src/Cocoar.Shelf/data/configuration.json new file mode 100644 index 0000000..aba7b9e --- /dev/null +++ b/src/Cocoar.Shelf/data/configuration.json @@ -0,0 +1,14 @@ +{ + "AppUrl": "http://0.0.0.0:8080", + "DocsRoot": "/data/docs", + "ConfigRoot": "/data/config", + "PathBase": "", + "ApiKey": "dev-key", + "MaxUploadSizeBytes": 104857600, + "Logging": { + "LogLevels": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 5d34c4d..dc5ad79 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/src/tests/Cocoar.Shelf.Tests/Cocoar.Shelf.Tests.csproj b/src/tests/Cocoar.Shelf.Tests/Cocoar.Shelf.Tests.csproj index b79460f..00ac667 100644 --- a/src/tests/Cocoar.Shelf.Tests/Cocoar.Shelf.Tests.csproj +++ b/src/tests/Cocoar.Shelf.Tests/Cocoar.Shelf.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/AdminApiTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/AdminApiTests.cs new file mode 100644 index 0000000..6098a6a --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/AdminApiTests.cs @@ -0,0 +1,313 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace Cocoar.Shelf.Tests.Integration; + +[Collection("Integration")] +public class AdminApiTests +{ + private readonly HttpClient _client; + private readonly ShelfFixture _fixture; + + public AdminApiTests(ShelfFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + } + + private HttpRequestMessage Auth(HttpRequestMessage request) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _fixture.ApiKey); + return request; + } + + private static StringContent Json(object body) => + new(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + + private async Task CreateProductViaApi(string name, string? displayName = null, string? description = null) + { + var request = Auth(new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = Json(new { name, displayName, description }) + }); + return await _client.SendAsync(request); + } + + #region Auth Verify + + [Fact] + public async Task Verify_Returns200_WithValidKey() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Get, "/_api/admin/verify")); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Verify_Returns401_WithoutKey() + { + var response = await _client.GetAsync("/_api/admin/verify"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + #endregion + + #region Create Product + + [Fact] + public async Task CreateProduct_Returns201_WithValidData() + { + var response = await CreateProductViaApi("admin-create-1", "Create Test", "A test product"); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json).RootElement; + Assert.Equal("admin-create-1", doc.GetProperty("name").GetString()); + Assert.Equal("Create Test", doc.GetProperty("displayName").GetString()); + } + + [Fact] + public async Task CreateProduct_Returns409_WhenAlreadyExists() + { + await CreateProductViaApi("admin-dup-1"); + + var response = await CreateProductViaApi("admin-dup-1"); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task CreateProduct_Returns400_WithEmptyName() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = Json(new { name = "" }) + }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task CreateProduct_Returns400_WithInvalidName() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = Json(new { name = "Invalid Name!" }) + }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task CreateProduct_Returns400_WithReservedName() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = Json(new { name = "admin" }) + }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("reserved", json); + } + + [Fact] + public async Task CreateProduct_Returns401_WithoutAuth() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = Json(new { name = "admin-unauth-1" }) + }; + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task CreateProduct_DefaultsSourceToUpload() + { + var response = await CreateProductViaApi("admin-default-src-1"); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json).RootElement; + Assert.Equal("upload", doc.GetProperty("source").GetString()); + } + + #endregion + + #region Update Product + + [Fact] + public async Task UpdateProduct_Returns200_WithValidData() + { + await CreateProductViaApi("admin-update-1", "Original"); + + var request = Auth(new HttpRequestMessage(HttpMethod.Put, "/_api/products/admin-update-1") + { + Content = Json(new { displayName = "Updated Name", description = "New desc" }) + }); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json).RootElement; + Assert.Equal("Updated Name", doc.GetProperty("displayName").GetString()); + } + + [Fact] + public async Task UpdateProduct_Returns404_WhenNotFound() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Put, "/_api/products/admin-nonexistent-update") + { + Content = Json(new { displayName = "Nope" }) + }); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task UpdateProduct_PreservesExistingFields_WhenNotProvided() + { + await CreateProductViaApi("admin-partial-1", "Original", "Original Desc"); + + var request = Auth(new HttpRequestMessage(HttpMethod.Put, "/_api/products/admin-partial-1") + { + Content = Json(new { displayName = "Changed" }) + }); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json).RootElement; + Assert.Equal("Changed", doc.GetProperty("displayName").GetString()); + Assert.Equal("Original Desc", doc.GetProperty("description").GetString()); + } + + #endregion + + #region Delete Product + + [Fact] + public async Task DeleteProduct_Returns204_WhenExists() + { + await CreateProductViaApi("admin-delete-1"); + + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, "/_api/products/admin-delete-1")); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } + + [Fact] + public async Task DeleteProduct_Returns404_WhenNotFound() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, "/_api/products/admin-nonexistent-del")); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + #endregion + + #region Delete Version + + [Fact] + public async Task DeleteVersion_Returns204_WhenExists() + { + await CreateProductViaApi("admin-verdel-1"); + _fixture.CreateVersionDirectory("admin-verdel-1", "v1", ""); + _fixture.CreateVersionDirectory("admin-verdel-1", "v2", ""); + + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, + "/_api/products/admin-verdel-1/versions/v1")); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.False(Directory.Exists(Path.Combine(_fixture.DocsRoot, "admin-verdel-1", "v1"))); + Assert.True(Directory.Exists(Path.Combine(_fixture.DocsRoot, "admin-verdel-1", "v2"))); + } + + [Fact] + public async Task DeleteVersion_Returns404_WhenVersionNotFound() + { + await CreateProductViaApi("admin-ver404-1"); + + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, + "/_api/products/admin-ver404-1/versions/v99")); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DeleteVersion_Returns404_WhenProductNotRegistered() + { + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, + "/_api/products/admin-unregistered-verdel/versions/v1")); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DeleteVersion_Returns400_WithInvalidVersionFormat() + { + await CreateProductViaApi("admin-badver-1"); + + var request = Auth(new HttpRequestMessage(HttpMethod.Delete, + "/_api/products/admin-badver-1/versions/not-a-version")); + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + #endregion + + #region SPA Fallback + + [Fact] + public async Task AdminRoute_ReturnsHtml_ForClientSideRoutes() + { + var response = await _client.GetAsync("/admin/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task AdminRoute_ReturnsHtml_ForNestedRoutes() + { + var response = await _client.GetAsync("/admin/products/some-product"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task AdminRoute_DoesNotInterfereWithApi() + { + var response = await _client.GetAsync("/_api/products"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + } + + #endregion +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/ApiProductsTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/ApiProductsTests.cs new file mode 100644 index 0000000..d5cbb39 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/ApiProductsTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Cocoar.Shelf.Tests.Integration; + +[Collection("Integration")] +public class ApiProductsTests +{ + private readonly HttpClient _client; + private readonly ShelfFixture _fixture; + + public ApiProductsTests(ShelfFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + } + + [Fact] + public async Task GetProducts_ReturnsRegisteredProduct() + { + await _fixture.RegisterProductViaApi(_client, "test-list", "Test Product", "A test"); + _fixture.CreateVersionDirectory("test-list", "v1", ""); + + var response = await _client.GetAsync("/_api/products"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var products = JsonDocument.Parse(json).RootElement; + + var product = products.EnumerateArray().FirstOrDefault(p => p.GetProperty("name").GetString() == "test-list"); + Assert.Equal("Test Product", product.GetProperty("displayName").GetString()); + } + + [Fact] + public async Task GetVersions_Returns404_WhenProductNotRegistered() + { + var response = await _client.GetAsync("/_api/products/nonexistent/versions"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("not registered", json); + } + + [Fact] + public async Task GetVersions_ReturnsVersions_ForRegisteredProduct() + { + await _fixture.RegisterProductViaApi(_client, "test-versions"); + _fixture.CreateVersionDirectory("test-versions", "v1", ""); + _fixture.CreateVersionDirectory("test-versions", "v2", ""); + + var response = await _client.GetAsync("/_api/products/test-versions/versions"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + var doc = JsonDocument.Parse(json).RootElement; + + var versions = doc.GetProperty("versions").EnumerateArray().Select(v => v.GetString()).ToList(); + Assert.Contains("v1", versions); + Assert.Contains("v2", versions); + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/DocsServingTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/DocsServingTests.cs new file mode 100644 index 0000000..784e198 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/DocsServingTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace Cocoar.Shelf.Tests.Integration; + +[Collection("Integration")] +public class DocsServingTests +{ + private readonly HttpClient _client; + private readonly ShelfFixture _fixture; + + public DocsServingTests(ShelfFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(new() { AllowAutoRedirect = false }); + _fixture.RegisterProductViaApi(_client, "docs-test").GetAwaiter().GetResult(); + } + + private async Task UploadVersion(string product, string version, params (string name, string content)[] entries) + { + using var zip = ZipHelper.Create(entries); + var request = new HttpRequestMessage(HttpMethod.Post, $"/_api/products/{product}/versions/{version}") + { + Content = ZipHelper.ToContent(zip) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _fixture.ApiKey); + var response = await _client.SendAsync(request); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Fact] + public async Task ServesIndexHtml_ForVersionRoot() + { + await UploadVersion("docs-test", "v20", ("index.html", "v20")); + + var response = await _client.GetAsync("/docs-test/v20/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("v20", content); + } + + [Fact] + public async Task ServesSubPage() + { + await UploadVersion("docs-test", "v21", + ("index.html", ""), + ("guide/getting-started.html", "Guide")); + + var response = await _client.GetAsync("/docs-test/v21/guide/getting-started.html"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("Guide", content); + } + + [Fact] + public async Task RedirectsToLatest_WhenNoVersionSpecified() + { + await UploadVersion("docs-test", "v22", ("index.html", "")); + + var response = await _client.GetAsync("/docs-test/"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Contains("/docs-test/", response.Headers.Location?.ToString() ?? ""); + } + + [Fact] + public async Task Returns404_ForNonExistentFile() + { + await UploadVersion("docs-test", "v23", ("index.html", "")); + + var response = await _client.GetAsync("/docs-test/v23/nonexistent.html"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task RewritesBasePath_InHtml() + { + await UploadVersion("docs-test", "v24", + ("index.html", """Test""")); + + var response = await _client.GetAsync("/docs-test/v24/"); + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains("href=\"/docs-test/v24/assets/style.css\"", content); + Assert.DoesNotContain("href=\"/assets/style.css\"", content); + } + + [Fact] + public async Task SetsImmutableCacheHeaders_ForHashedAssets() + { + await UploadVersion("docs-test", "v25", + ("index.html", ""), + ("assets/style.a1b2c3.css", "body{}")); + + var response = await _client.GetAsync("/docs-test/v25/assets/style.a1b2c3.css"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var cacheControl = response.Headers.CacheControl; + Assert.NotNull(cacheControl); + Assert.True(cacheControl.Public); + Assert.Equal(TimeSpan.FromSeconds(31536000), cacheControl.MaxAge); + } + + [Fact] + public async Task ServesPlainTextFiles() + { + await UploadVersion("docs-test", "v26", + ("index.html", ""), + ("llms.txt", "# Documentation for LLMs")); + + var response = await _client.GetAsync("/docs-test/v26/llms.txt"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("# Documentation for LLMs", content); + } + + [Fact] + public async Task LandingPage_Returns200() + { + var autoRedirectClient = _fixture.CreateClient(); + + var response = await autoRedirectClient.GetAsync("/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("html", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task LatestRedirect_PrefersStableOverPreRelease() + { + await _fixture.RegisterProductViaApi(_client, "stable-test"); + await UploadVersion("stable-test", "v2.0.0", ("index.html", "stable")); + await UploadVersion("stable-test", "v3.0.0-beta.1", ("index.html", "beta")); + + var response = await _client.GetAsync("/stable-test/"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var location = response.Headers.Location?.ToString() ?? ""; + Assert.Contains("v2.0.0", location); + Assert.DoesNotContain("beta", location); + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/IntegrationTestCollection.cs b/src/tests/Cocoar.Shelf.Tests/Integration/IntegrationTestCollection.cs new file mode 100644 index 0000000..83408fc --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/IntegrationTestCollection.cs @@ -0,0 +1,4 @@ +namespace Cocoar.Shelf.Tests.Integration; + +[CollectionDefinition("Integration")] +public class IntegrationTestCollection : ICollectionFixture; diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/ShelfFixture.cs b/src/tests/Cocoar.Shelf.Tests/Integration/ShelfFixture.cs new file mode 100644 index 0000000..e2a6711 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/ShelfFixture.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Cocoar.Shelf.Tests.Integration; + +public class ShelfFixture : WebApplicationFactory, IAsyncLifetime +{ + public string DocsRoot { get; private set; } = null!; + public string ConfigRoot { get; private set; } = null!; + public string ApiKey => "test-api-key"; + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false }; + + public Task InitializeAsync() + { + var baseDir = Path.Combine(Path.GetTempPath(), $"shelf-integration-{Guid.NewGuid():N}"); + DocsRoot = Path.Combine(baseDir, "docs"); + ConfigRoot = Path.Combine(baseDir, "config"); + + Directory.CreateDirectory(DocsRoot); + Directory.CreateDirectory(Path.Combine(ConfigRoot, "products")); + + // Set env vars that FromEnvironment("Shelf__") picks up (overrides appsettings.json) + Environment.SetEnvironmentVariable("Shelf__DocsRoot", DocsRoot); + Environment.SetEnvironmentVariable("Shelf__ConfigRoot", ConfigRoot); + Environment.SetEnvironmentVariable("Shelf__ApiKey", ApiKey); + + return Task.CompletedTask; + } + + public new Task DisposeAsync() + { + base.Dispose(); + + Environment.SetEnvironmentVariable("Shelf__DocsRoot", null); + Environment.SetEnvironmentVariable("Shelf__ConfigRoot", null); + Environment.SetEnvironmentVariable("Shelf__ApiKey", null); + + try + { + var baseDir = Path.GetDirectoryName(DocsRoot)!; + Directory.Delete(baseDir, recursive: true); + } + catch { /* best-effort */ } + + return Task.CompletedTask; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Production"); + } + + public async Task RegisterProductViaApi(HttpClient client, string name, string? displayName = null, string? description = null) + { + var body = new { name, displayName = displayName ?? name, description = description ?? "", source = "upload" }; + var request = new HttpRequestMessage(HttpMethod.Post, "/_api/products") + { + Content = new StringContent(JsonSerializer.Serialize(body, JsonOptions), System.Text.Encoding.UTF8, "application/json") + }; + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", ApiKey); + var response = await client.SendAsync(request); + + // 201 = created, 409 = already exists (both OK for test setup) + if (response.StatusCode != System.Net.HttpStatusCode.Created && + response.StatusCode != System.Net.HttpStatusCode.Conflict) + { + throw new InvalidOperationException($"Failed to register product '{name}': {response.StatusCode}"); + } + } + + public void RegisterProduct(string name, string? displayName = null, string? description = null) + { + var config = new { name, displayName = displayName ?? name, description = description ?? "", source = "upload" }; + File.WriteAllText( + Path.Combine(ConfigRoot, "products", $"{name}.json"), + JsonSerializer.Serialize(config, JsonOptions)); + } + + public void CreateVersionDirectory(string product, string version, string? indexHtml = null) + { + var dir = Path.Combine(DocsRoot, product, version); + Directory.CreateDirectory(dir); + + if (indexHtml != null) + File.WriteAllText(Path.Combine(dir, "index.html"), indexHtml); + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/UploadApiTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/UploadApiTests.cs new file mode 100644 index 0000000..40bcf09 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/UploadApiTests.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace Cocoar.Shelf.Tests.Integration; + +[Collection("Integration")] +public class UploadApiTests +{ + private readonly HttpClient _client; + private readonly ShelfFixture _fixture; + + public UploadApiTests(ShelfFixture fixture) + { + _fixture = fixture; + _client = fixture.CreateClient(); + _fixture.RegisterProductViaApi(_client, "upload-test").GetAwaiter().GetResult(); + } + + private HttpRequestMessage CreateUploadRequest(string product, string version, HttpContent content) + { + var request = new HttpRequestMessage(HttpMethod.Post, $"/_api/products/{product}/versions/{version}") + { + Content = content + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _fixture.ApiKey); + return request; + } + + [Fact] + public async Task Upload_Returns201_WithValidZip() + { + using var zip = ZipHelper.Create(("index.html", ""), ("assets/app.js", "console.log('hi');")); + var request = CreateUploadRequest("upload-test", "v10", ZipHelper.ToContent(zip)); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.True(File.Exists(Path.Combine(_fixture.DocsRoot, "upload-test", "v10", "index.html"))); + } + + [Fact] + public async Task Upload_Returns401_WithoutAuth() + { + using var zip = ZipHelper.Create(("index.html", "")); + var request = new HttpRequestMessage(HttpMethod.Post, "/_api/products/upload-test/versions/v11") + { + Content = ZipHelper.ToContent(zip) + }; + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Upload_Returns401_WithWrongKey() + { + using var zip = ZipHelper.Create(("index.html", "")); + var request = new HttpRequestMessage(HttpMethod.Post, "/_api/products/upload-test/versions/v12") + { + Content = ZipHelper.ToContent(zip) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "wrong-key"); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Upload_Returns400_WithInvalidVersionFormat() + { + using var zip = ZipHelper.Create(("index.html", "")); + var request = CreateUploadRequest("upload-test", "not-a-version", ZipHelper.ToContent(zip)); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("Invalid version format", json); + Assert.Contains("Must match pattern", json); + } + + [Fact] + public async Task Upload_Returns404_ForUnregisteredProduct() + { + using var zip = ZipHelper.Create(("index.html", "")); + var request = CreateUploadRequest("unregistered-product", "v1", ZipHelper.ToContent(zip)); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("not registered", json); + } + + [Fact] + public async Task Upload_Returns400_WithCorruptZip() + { + var content = new ByteArrayContent("this is not a zip"u8.ToArray()); + content.Headers.ContentType = new("application/zip"); + var request = CreateUploadRequest("upload-test", "v13", content); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("Invalid", json); + } + + [Fact] + public async Task Upload_Returns400_WithoutIndexHtml() + { + using var zip = ZipHelper.Create(("readme.txt", "hello"), ("assets/style.css", "body{}")); + var request = CreateUploadRequest("upload-test", "v14", ZipHelper.ToContent(zip)); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("index.html", json); + } + + [Fact] + public async Task Upload_ReplacesExistingVersion() + { + using var zip1 = ZipHelper.Create(("index.html", "original")); + var r1 = CreateUploadRequest("upload-test", "v15", ZipHelper.ToContent(zip1)); + await _client.SendAsync(r1); + + using var zip2 = ZipHelper.Create(("index.html", "updated")); + var r2 = CreateUploadRequest("upload-test", "v15", ZipHelper.ToContent(zip2)); + var response = await _client.SendAsync(r2); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var content = File.ReadAllText(Path.Combine(_fixture.DocsRoot, "upload-test", "v15", "index.html")); + Assert.Equal("updated", content); + } + + [Fact] + public async Task Upload_AcceptsSemVerVersions() + { + var versions = new[] { "v1", "v5.2", "v5.2.0", "5.0.0", "v6.0.0-beta.1" }; + + foreach (var version in versions) + { + using var zip = ZipHelper.Create(("index.html", $"{version}")); + var request = CreateUploadRequest("upload-test", version, ZipHelper.ToContent(zip)); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + } + + [Fact] + public async Task Upload_IsVisibleInVersionsList() + { + await _fixture.RegisterProductViaApi(_client, "upload-visible"); + using var zip = ZipHelper.Create(("index.html", "")); + var request = CreateUploadRequest("upload-visible", "v3", ZipHelper.ToContent(zip)); + await _client.SendAsync(request); + + var response = await _client.GetAsync("/_api/products/upload-visible/versions"); + var json = await response.Content.ReadAsStringAsync(); + var doc = System.Text.Json.JsonDocument.Parse(json).RootElement; + + Assert.Equal("v3", doc.GetProperty("latest").GetString()); + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/UploadDisabledTests.cs b/src/tests/Cocoar.Shelf.Tests/Integration/UploadDisabledTests.cs new file mode 100644 index 0000000..49b13de --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/UploadDisabledTests.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Net.Http.Headers; +using Cocoar.Configuration.Reactive; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace Cocoar.Shelf.Tests.Integration; + +[Collection("Integration")] +public class UploadDisabledTests +{ + private readonly HttpClient _client; + + public UploadDisabledTests(ShelfFixture fixture) + { + fixture.RegisterProduct("disabled-test"); + + // Create a separate factory that overrides ApiKey to empty + var factory = fixture.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + // Replace the scoped ShelfOptions with one that has no API key + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(ShelfOptions)); + if (descriptor != null) + services.Remove(descriptor); + + services.AddScoped(sp => + { + var config = sp.GetRequiredService>(); + var opts = config.CurrentValue; + return new ShelfOptions + { + DocsRoot = opts.DocsRoot, + ConfigRoot = opts.ConfigRoot, + ApiKey = "", + VersionPattern = opts.VersionPattern, + BasePlaceholder = opts.BasePlaceholder, + MaxUploadSizeBytes = opts.MaxUploadSizeBytes, + PathBase = opts.PathBase + }; + }); + }); + }); + + _client = factory.CreateClient(); + } + + [Fact] + public async Task Upload_Returns503_WhenApiKeyNotConfigured() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/_api/products/disabled-test/versions/v1") + { + Content = new ByteArrayContent(Array.Empty()) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "any-key"); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + var json = await response.Content.ReadAsStringAsync(); + Assert.Contains("disabled", json); + } + + [Fact] + public async Task ReadEndpoints_StillWork_WhenUploadDisabled() + { + var response = await _client.GetAsync("/_api/products"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/Integration/ZipHelper.cs b/src/tests/Cocoar.Shelf.Tests/Integration/ZipHelper.cs new file mode 100644 index 0000000..1d1484a --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/Integration/ZipHelper.cs @@ -0,0 +1,28 @@ +using System.IO.Compression; + +namespace Cocoar.Shelf.Tests.Integration; + +internal static class ZipHelper +{ + public static MemoryStream Create(params (string name, string content)[] entries) + { + var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + using var writer = new StreamWriter(archive.CreateEntry(name).Open()); + writer.Write(content); + } + } + ms.Position = 0; + return ms; + } + + public static ByteArrayContent ToContent(MemoryStream zip) + { + var content = new ByteArrayContent(zip.ToArray()); + content.Headers.ContentType = new("application/zip"); + return content; + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceWriteTests.cs b/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceWriteTests.cs new file mode 100644 index 0000000..f18df15 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/ProductConfigServiceWriteTests.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using Cocoar.Shelf.Models; +using Cocoar.Shelf.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cocoar.Shelf.Tests; + +public sealed class ProductConfigServiceWriteTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _productsDir; + + public ProductConfigServiceWriteTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"shelf-write-tests-{Guid.NewGuid():N}"); + _productsDir = Path.Combine(_tempDir, "products"); + Directory.CreateDirectory(_productsDir); + } + + private ProductConfigService CreateService() + { + var config = new TestReactiveConfig(new ShelfOptions { ConfigRoot = _tempDir }); + return new ProductConfigService(config, NullLogger.Instance); + } + + [Fact] + public async Task CreateAsync_WritesJsonFile() + { + using var sut = CreateService(); + var product = new ProductConfig { Name = "new-product", DisplayName = "New", Description = "Desc", Source = "upload" }; + + await sut.CreateAsync(product); + + Assert.True(File.Exists(Path.Combine(_productsDir, "new-product.json"))); + var result = sut.GetConfig("new-product"); + Assert.NotNull(result); + Assert.Equal("New", result.DisplayName); + } + + [Fact] + public async Task CreateAsync_ThrowsWhenAlreadyExists() + { + File.WriteAllText(Path.Combine(_productsDir, "existing.json"), + JsonSerializer.Serialize(new { name = "existing", source = "upload" })); + using var sut = CreateService(); + + var product = new ProductConfig { Name = "existing" }; + + await Assert.ThrowsAsync(() => sut.CreateAsync(product)); + } + + [Fact] + public async Task CreateAsync_ThrowsWithEmptyName() + { + using var sut = CreateService(); + var product = new ProductConfig { Name = "" }; + + await Assert.ThrowsAsync(() => sut.CreateAsync(product)); + } + + [Fact] + public async Task CreateAsync_IsImmediatelyReadable() + { + using var sut = CreateService(); + var product = new ProductConfig { Name = "immediate-read", DisplayName = "Test" }; + + await sut.CreateAsync(product); + + // Should be in cache immediately, not waiting for FileSystemWatcher + var result = sut.GetConfig("immediate-read"); + Assert.NotNull(result); + Assert.Equal("Test", result.DisplayName); + } + + [Fact] + public async Task UpdateAsync_OverwritesExisting() + { + File.WriteAllText(Path.Combine(_productsDir, "to-update.json"), + JsonSerializer.Serialize(new { name = "to-update", displayName = "Old", source = "upload" })); + using var sut = CreateService(); + + var updated = new ProductConfig { Name = "to-update", DisplayName = "New", Description = "Added", Source = "upload" }; + await sut.UpdateAsync(updated); + + var result = sut.GetConfig("to-update"); + Assert.NotNull(result); + Assert.Equal("New", result.DisplayName); + Assert.Equal("Added", result.Description); + } + + [Fact] + public async Task UpdateAsync_ThrowsWhenNotFound() + { + using var sut = CreateService(); + var product = new ProductConfig { Name = "nonexistent" }; + + await Assert.ThrowsAsync(() => sut.UpdateAsync(product)); + } + + [Fact] + public async Task DeleteAsync_RemovesFileAndReturnsTrue() + { + File.WriteAllText(Path.Combine(_productsDir, "to-delete.json"), + JsonSerializer.Serialize(new { name = "to-delete", source = "upload" })); + using var sut = CreateService(); + + Assert.NotNull(sut.GetConfig("to-delete")); + + var result = await sut.DeleteAsync("to-delete"); + + Assert.True(result); + Assert.False(File.Exists(Path.Combine(_productsDir, "to-delete.json"))); + Assert.Null(sut.GetConfig("to-delete")); + } + + [Fact] + public async Task DeleteAsync_ReturnsFalseWhenNotFound() + { + using var sut = CreateService(); + + var result = await sut.DeleteAsync("nonexistent"); + + Assert.False(result); + } + + [Fact] + public async Task CreateAsync_CreatesDirectoryIfMissing() + { + var emptyDir = Path.Combine(_tempDir, "empty-config"); + var config = new TestReactiveConfig(new ShelfOptions { ConfigRoot = emptyDir }); + using var sut = new ProductConfigService(config, NullLogger.Instance); + + var product = new ProductConfig { Name = "auto-dir" }; + await sut.CreateAsync(product); + + Assert.True(File.Exists(Path.Combine(emptyDir, "products", "auto-dir.json"))); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } + catch { /* best-effort */ } + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/UploadServiceDeleteTests.cs b/src/tests/Cocoar.Shelf.Tests/UploadServiceDeleteTests.cs new file mode 100644 index 0000000..d9ff095 --- /dev/null +++ b/src/tests/Cocoar.Shelf.Tests/UploadServiceDeleteTests.cs @@ -0,0 +1,74 @@ +using Cocoar.Shelf.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cocoar.Shelf.Tests; + +public sealed class UploadServiceDeleteTests : IDisposable +{ + private readonly string _docsRoot; + private readonly UploadService _sut; + + public UploadServiceDeleteTests() + { + _docsRoot = Path.Combine(Path.GetTempPath(), $"shelf-delete-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_docsRoot); + + var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = _docsRoot }); + _sut = new UploadService(config, NullLogger.Instance); + } + + [Fact] + public async Task DeleteVersion_RemovesDirectory_ReturnsTrue() + { + var versionDir = Path.Combine(_docsRoot, "myproduct", "v1"); + Directory.CreateDirectory(versionDir); + File.WriteAllText(Path.Combine(versionDir, "index.html"), ""); + + var result = await _sut.DeleteVersionAsync("myproduct", "v1"); + + Assert.True(result); + Assert.False(Directory.Exists(versionDir)); + } + + [Fact] + public async Task DeleteVersion_ReturnsFalse_WhenNotFound() + { + var result = await _sut.DeleteVersionAsync("myproduct", "v99"); + + Assert.False(result); + } + + [Fact] + public async Task DeleteVersion_PreservesOtherVersions() + { + Directory.CreateDirectory(Path.Combine(_docsRoot, "myproduct", "v1")); + Directory.CreateDirectory(Path.Combine(_docsRoot, "myproduct", "v2")); + + await _sut.DeleteVersionAsync("myproduct", "v1"); + + Assert.False(Directory.Exists(Path.Combine(_docsRoot, "myproduct", "v1"))); + Assert.True(Directory.Exists(Path.Combine(_docsRoot, "myproduct", "v2"))); + } + + [Fact] + public async Task DeleteVersion_RemovesAllContents() + { + var versionDir = Path.Combine(_docsRoot, "myproduct", "v3"); + Directory.CreateDirectory(Path.Combine(versionDir, "assets")); + Directory.CreateDirectory(Path.Combine(versionDir, "guide")); + File.WriteAllText(Path.Combine(versionDir, "index.html"), ""); + File.WriteAllText(Path.Combine(versionDir, "assets", "style.css"), "body{}"); + File.WriteAllText(Path.Combine(versionDir, "guide", "page.html"), ""); + + var result = await _sut.DeleteVersionAsync("myproduct", "v3"); + + Assert.True(result); + Assert.False(Directory.Exists(versionDir)); + } + + public void Dispose() + { + try { Directory.Delete(_docsRoot, recursive: true); } + catch { /* best-effort */ } + } +} From 5adabdc07a039a0f8c0bea7ffbfb7362d2340eae Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 21 Mar 2026 17:28:56 +0100 Subject: [PATCH 3/8] feat: enhance version validation and output format in CI/CD workflow - Updated version format validation to allow pre-release versions (e.g., 1.2.3-beta.1). - Adjusted output to include full version for pre-releases and major.minor for stable releases. feat: update index.html for improved LLM documentation access - Changed page title from "Shelf Admin" to "Shelf". - Added a link to machine-readable LLM documentation index. feat: extend TypeScript definitions for new UI components - Added declarations for CoarSelect, CoarSpinner, CoarTable, and CoarTag components. fix: improve HTTP request handling and authentication flow - Added credentials to fetch requests for cookie-based authentication. - Updated logout function to clear authentication state properly. feat: add visibility property to product models and requests - Introduced visibility property in Product interface and related request models. - Updated product creation and update requests to include visibility. feat: enhance admin layout with async logout handling - Changed logout function to be asynchronous for better handling of authentication state. feat: implement session checking in router before navigation - Added session check on first navigation to ensure user authentication. feat: improve product landing view with preview filtering - Added functionality to toggle visibility of preview products in the landing view. feat: enhance product detail view with visibility information - Displayed product visibility status using CoarTag component. feat: update product form view to include visibility selection - Added a dropdown for selecting product visibility in the product form. feat: improve product list view with visibility display - Updated product list to show visibility status using CoarTag component. feat: implement authentication endpoints for login and session management - Added login, logout, and user info retrieval endpoints with cookie-based authentication. feat: create LLM documentation index endpoint - Implemented /llms.txt endpoint to generate a machine-readable index of documentation. refactor: remove unused SPA fallback middleware - Deleted SpaFallbackMiddleware as it is no longer needed. fix: update API key filter to support cookie-based authentication - Enhanced ApiKeyFilter to accept both cookie sessions and Bearer API keys for authentication. --- .github/workflows/cd-deploy-docs.yml | 2 +- .github/workflows/cd-deploy-production.yml | 16 ++- src/Cocoar.Shelf.Client/index.html | 4 +- .../src/cocoar-modules.d.ts | 4 + src/Cocoar.Shelf.Client/src/core/api/http.ts | 27 +++-- .../src/core/models/shelf.models.ts | 3 + .../src/layouts/AdminLayout.vue | 4 +- src/Cocoar.Shelf.Client/src/router/index.ts | 11 +- .../src/stores/auth.store.ts | 55 +++++++-- .../src/views/LandingView.vue | 114 ++++++++++++++++-- .../src/views/products/ProductDetailView.vue | 73 ++++------- .../src/views/products/ProductFormView.vue | 42 +++++-- .../src/views/products/ProductListView.vue | 73 +++-------- src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs | 14 ++- src/Cocoar.Shelf/Endpoints/ApiKeyFilter.cs | 18 ++- src/Cocoar.Shelf/Endpoints/AuthEndpoints.cs | 84 +++++++++++++ src/Cocoar.Shelf/Endpoints/LlmsTxtEndpoint.cs | 76 ++++++++++++ .../Middleware/SpaFallbackMiddleware.cs | 26 ---- src/Cocoar.Shelf/Models/ProductConfig.cs | 2 + src/Cocoar.Shelf/Models/ProductRequests.cs | 4 +- src/Cocoar.Shelf/Program.cs | 26 +++- .../Integration/AdminApiTests.cs | 34 ++++-- 22 files changed, 494 insertions(+), 218 deletions(-) create mode 100644 src/Cocoar.Shelf/Endpoints/AuthEndpoints.cs create mode 100644 src/Cocoar.Shelf/Endpoints/LlmsTxtEndpoint.cs delete mode 100644 src/Cocoar.Shelf/Middleware/SpaFallbackMiddleware.cs diff --git a/.github/workflows/cd-deploy-docs.yml b/.github/workflows/cd-deploy-docs.yml index 642e6bd..d2f7ae1 100644 --- a/.github/workflows/cd-deploy-docs.yml +++ b/.github/workflows/cd-deploy-docs.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: 'Version (e.g. v1.0, v1.1)' + description: 'Version (e.g. v1.0, v1.1, v2.0.0-beta.1)' required: true concurrency: diff --git a/.github/workflows/cd-deploy-production.yml b/.github/workflows/cd-deploy-production.yml index 09df33d..d5ce22a 100644 --- a/.github/workflows/cd-deploy-production.yml +++ b/.github/workflows/cd-deploy-production.yml @@ -29,8 +29,8 @@ jobs: exit 1 fi - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error ::Invalid release version format. Expected X.Y.Z (e.g., 1.2.3), got: $VERSION" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.+)?$ ]]; then + echo "::error ::Invalid release version format. Expected X.Y.Z or X.Y.Z-label (e.g., 1.2.3, 1.2.3-beta.1), got: $VERSION" exit 1 fi @@ -205,9 +205,15 @@ jobs: env: VERSION: ${{ needs.validate-version.outputs.version }} run: | - MAJOR=$(echo "$VERSION" | cut -d. -f1) - MINOR=$(echo "$VERSION" | cut -d. -f2) - echo "value=v${MAJOR}.${MINOR}" >> $GITHUB_OUTPUT + if [[ "$VERSION" == *-* ]]; then + # Pre-release: use full version (e.g. v6.0.0-beta.1) + echo "value=v${VERSION}" >> $GITHUB_OUTPUT + else + # Stable: use major.minor (e.g. v5.2) + MAJOR=$(echo "$VERSION" | cut -d. -f1) + MINOR=$(echo "$VERSION" | cut -d. -f2) + echo "value=v${MAJOR}.${MINOR}" >> $GITHUB_OUTPUT + fi - name: Upload to Shelf env: diff --git a/src/Cocoar.Shelf.Client/index.html b/src/Cocoar.Shelf.Client/index.html index 385325b..1a39d2c 100644 --- a/src/Cocoar.Shelf.Client/index.html +++ b/src/Cocoar.Shelf.Client/index.html @@ -3,10 +3,12 @@ - Shelf Admin + Shelf +
+ diff --git a/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts b/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts index 8ab412f..d34bcd3 100644 --- a/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts +++ b/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts @@ -15,6 +15,10 @@ declare module '@cocoar/vue-ui' { export const CoarIcon: Component; export const CoarNote: Component; export const CoarOverlayHost: Component; + export const CoarSelect: Component; + export const CoarSpinner: Component; + export const CoarTable: Component; + export const CoarTag: Component; export const CoarTextInput: Component; } diff --git a/src/Cocoar.Shelf.Client/src/core/api/http.ts b/src/Cocoar.Shelf.Client/src/core/api/http.ts index c38d476..463b662 100644 --- a/src/Cocoar.Shelf.Client/src/core/api/http.ts +++ b/src/Cocoar.Shelf.Client/src/core/api/http.ts @@ -14,20 +14,21 @@ async function request(path: string, init: RequestInit = {}): Promise { ...(init.headers as Record), }; - const auth = useAuthStore(); - if (auth.apiKey) { - headers['Authorization'] = `Bearer ${auth.apiKey}`; - } - if (init.body && typeof init.body === 'string') { headers['Content-Type'] = 'application/json'; } - const response = await fetch(`/_api${path}`, { ...init, headers }); + const response = await fetch(`/_api${path}`, { + ...init, + headers, + credentials: 'include', + }); if (!response.ok) { if (response.status === 401) { - auth.logout(); + const auth = useAuthStore(); + auth.isAuthenticated = false; + auth.userName = null; router.push('/login'); } const contentType = response.headers.get('content-type') ?? ''; @@ -51,10 +52,10 @@ export const http = { put: (path: string, body?: unknown) => request(path, { method: 'PUT', body: body !== undefined ? JSON.stringify(body) : undefined }), delete: (path: string) => request(path, { method: 'DELETE' }), - upload: (path: string, file: File | Blob) => { - const headers: Record = { 'Content-Type': 'application/zip' }; - const auth = useAuthStore(); - if (auth.apiKey) headers['Authorization'] = `Bearer ${auth.apiKey}`; - return request(path, { method: 'POST', body: file, headers }); - }, + upload: (path: string, file: File | Blob) => + request(path, { + method: 'POST', + body: file, + headers: { 'Content-Type': 'application/zip' }, + }), }; diff --git a/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts index 1645959..baf2f2c 100644 --- a/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts +++ b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts @@ -3,6 +3,7 @@ export interface Product { displayName: string | null; description: string | null; source: string; + visibility: string; latest: string | null; versions: string[]; } @@ -18,10 +19,12 @@ export interface CreateProductRequest { displayName?: string; description?: string; source?: string; + visibility?: string; } export interface UpdateProductRequest { displayName?: string; description?: string; source?: string; + visibility?: string; } diff --git a/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue b/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue index 7e04a97..46ee765 100644 --- a/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue +++ b/src/Cocoar.Shelf.Client/src/layouts/AdminLayout.vue @@ -88,8 +88,8 @@ const router = useRouter(); const ui = useUI(); const auth = useAuthStore(); -function onLogout() { - auth.logout(); +async function onLogout() { + await auth.logout(); router.push('/login'); } diff --git a/src/Cocoar.Shelf.Client/src/router/index.ts b/src/Cocoar.Shelf.Client/src/router/index.ts index eb40022..e88fb23 100644 --- a/src/Cocoar.Shelf.Client/src/router/index.ts +++ b/src/Cocoar.Shelf.Client/src/router/index.ts @@ -28,8 +28,17 @@ export const router = createRouter({ ], }); -router.beforeEach((to) => { +let sessionChecked = false; + +router.beforeEach(async (to) => { const auth = useAuthStore(); + + // Check session once on first navigation (handles page refresh) + if (!sessionChecked) { + sessionChecked = true; + await auth.checkSession(); + } + if (!to.meta.public && !auth.isAuthenticated) { return '/login'; } diff --git a/src/Cocoar.Shelf.Client/src/stores/auth.store.ts b/src/Cocoar.Shelf.Client/src/stores/auth.store.ts index 0c1b642..1414cfd 100644 --- a/src/Cocoar.Shelf.Client/src/stores/auth.store.ts +++ b/src/Cocoar.Shelf.Client/src/stores/auth.store.ts @@ -2,31 +2,60 @@ import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; export const useAuthStore = defineStore('auth', () => { - const apiKey = ref(sessionStorage.getItem('shelf_api_key')); - const isAuthenticated = computed(() => !!apiKey.value); + const isAuthenticated = ref(false); + const userName = ref(null); - async function login(key: string): Promise { - apiKey.value = key; + async function checkSession(): Promise { try { - const response = await fetch('/_api/admin/verify', { - headers: { 'Authorization': `Bearer ${key}` }, + const response = await fetch('/_api/auth/me', { credentials: 'include' }); + if (response.ok) { + const data = await response.json(); + isAuthenticated.value = data.authenticated; + userName.value = data.name; + return true; + } + isAuthenticated.value = false; + userName.value = null; + return false; + } catch { + isAuthenticated.value = false; + userName.value = null; + return false; + } + } + + async function login(apiKey: string): Promise { + try { + const response = await fetch('/_api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ apiKey }), }); + if (response.ok) { - sessionStorage.setItem('shelf_api_key', key); + const data = await response.json(); + isAuthenticated.value = true; + userName.value = data.name; return true; } - apiKey.value = null; return false; } catch { - apiKey.value = null; return false; } } - function logout() { - apiKey.value = null; - sessionStorage.removeItem('shelf_api_key'); + async function logout() { + try { + await fetch('/_api/auth/logout', { + method: 'POST', + credentials: 'include', + }); + } finally { + isAuthenticated.value = false; + userName.value = null; + } } - return { apiKey, isAuthenticated, login, logout }; + return { isAuthenticated, userName, checkSession, login, logout }; }); diff --git a/src/Cocoar.Shelf.Client/src/views/LandingView.vue b/src/Cocoar.Shelf.Client/src/views/LandingView.vue index 9a0eb18..db8d40e 100644 --- a/src/Cocoar.Shelf.Client/src/views/LandingView.vue +++ b/src/Cocoar.Shelf.Client/src/views/LandingView.vue @@ -4,24 +4,37 @@

Documentation

+
+ +
+
-
{{ product.displayName || product.name }}
+
+
{{ product.displayName || product.name }}
+ preview +
{{ product.description }}
-
+ -
+
No documentation available yet.
- - + + + + + + Shelf + + + + +
+ + + + diff --git a/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts b/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts index d34bcd3..5bf0a15 100644 --- a/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts +++ b/src/Cocoar.Shelf.Client/src/cocoar-modules.d.ts @@ -1,25 +1,29 @@ -declare module '@cocoar/vue-ui' { - import type { Plugin, Component } from 'vue'; - - export const CoarIconPlugin: Plugin; - export const CoarOverlayPlugin: Plugin; - export const CORE_ICONS: unknown; - - export class CoarHttpIconSource { - constructor(resolver: (name: string) => string); - } - - export const CoarButton: Component; - export const CoarCard: Component; - export const CoarCheckbox: Component; - export const CoarIcon: Component; - export const CoarNote: Component; - export const CoarOverlayHost: Component; - export const CoarSelect: Component; - export const CoarSpinner: Component; - export const CoarTable: Component; - export const CoarTag: Component; - export const CoarTextInput: Component; -} - -declare module '@cocoar/vue-ui/styles' {} +declare module '@cocoar/vue-ui' { + import type { Plugin, Component } from 'vue'; + + export const CoarIconPlugin: Plugin; + export const CoarOverlayPlugin: Plugin; + export const CORE_ICONS: unknown; + + export class CoarHttpIconSource { + constructor(resolver: (name: string) => string); + } + + export const CoarButton: Component; + export const CoarCard: Component; + export const CoarCheckbox: Component; + export const CoarIcon: Component; + export const CoarNote: Component; + export const CoarOverlayHost: Component; + export const CoarSelect: Component; + export const CoarSpinner: Component; + export const CoarTable: Component; + export const CoarTag: Component; + export const CoarTextInput: Component; +} + +declare module '@cocoar/vue-ui/styles' {} + +declare interface Window { + __SHELF_OPTIONS__: { pathBase: string }; +} diff --git a/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts b/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts index dc83b90..43a084c 100644 --- a/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts +++ b/src/Cocoar.Shelf.Client/src/core/api/shelf-api.ts @@ -1,17 +1,17 @@ -import { http } from './http'; -import type { Product, ProductVersions, CreateProductRequest, UpdateProductRequest } from '../models/shelf.models'; - -export const shelfApi = { - verifyApiKey: () => http.get<{ ok: boolean }>('/admin/verify'), - - getProducts: () => http.get('/products'), - getVersions: (product: string) => http.get(`/products/${product}/versions`), - createProduct: (req: CreateProductRequest) => http.post('/products', req), - updateProduct: (name: string, req: UpdateProductRequest) => http.put(`/products/${name}`, req), - deleteProduct: (name: string) => http.delete(`/products/${name}`), - - deleteVersion: (product: string, version: string) => - http.delete(`/products/${product}/versions/${version}`), - uploadVersion: (product: string, version: string, file: File | Blob) => - http.upload(`/products/${product}/versions/${version}`, file), -}; +import { http } from './http'; +import type { Product, ProductVersions, CreateProductRequest, UpdateProductRequest } from '../models/shelf.models'; + +export const shelfApi = { + getProducts: () => http.get('/products'), + getProduct: (name: string) => http.get(`/products/${name}`), + getVersions: (product: string) => http.get(`/products/${product}/versions`), + createProduct: (req: CreateProductRequest) => http.post('/products', req), + updateProduct: (name: string, req: UpdateProductRequest) => http.put(`/products/${name}`, req), + deleteProduct: (name: string, deleteData = false) => + http.delete(`/products/${name}${deleteData ? '?deleteData=true' : ''}`), + + deleteVersion: (product: string, version: string) => + http.delete(`/products/${product}/versions/${version}`), + uploadVersion: (product: string, version: string, file: File | Blob) => + http.upload(`/products/${product}/versions/${version}`, file), +}; diff --git a/src/Cocoar.Shelf.Client/src/router/index.ts b/src/Cocoar.Shelf.Client/src/router/index.ts index e88fb23..ddc0473 100644 --- a/src/Cocoar.Shelf.Client/src/router/index.ts +++ b/src/Cocoar.Shelf.Client/src/router/index.ts @@ -1,48 +1,50 @@ -import { createRouter, createWebHistory } from 'vue-router'; -import { useAuthStore } from '@/stores/auth.store'; - -export const router = createRouter({ - history: createWebHistory('/'), - routes: [ - { - path: '/', - component: () => import('@/views/LandingView.vue'), - meta: { public: true }, - }, - { - path: '/login', - component: () => import('@/views/LoginView.vue'), - meta: { public: true }, - }, - { - path: '/admin', - component: () => import('@/layouts/AdminLayout.vue'), - children: [ - { path: '', component: () => import('@/views/DashboardView.vue') }, - { path: 'products', component: () => import('@/views/products/ProductListView.vue') }, - { path: 'products/create', component: () => import('@/views/products/ProductFormView.vue') }, - { path: 'products/:name', component: () => import('@/views/products/ProductDetailView.vue') }, - { path: 'products/:name/edit', component: () => import('@/views/products/ProductFormView.vue') }, - ], - }, - ], -}); - -let sessionChecked = false; - -router.beforeEach(async (to) => { - const auth = useAuthStore(); - - // Check session once on first navigation (handles page refresh) - if (!sessionChecked) { - sessionChecked = true; - await auth.checkSession(); - } - - if (!to.meta.public && !auth.isAuthenticated) { - return '/login'; - } - if (to.path === '/login' && auth.isAuthenticated) { - return '/admin'; - } -}); +import { createRouter, createWebHistory } from 'vue-router'; +import { useAuthStore } from '@/stores/auth.store'; + +const pathBase = (window.__SHELF_OPTIONS__?.pathBase ?? '').replace(/\/$/, ''); + +export const router = createRouter({ + history: createWebHistory(pathBase + '/'), + routes: [ + { + path: '/', + component: () => import('@/views/LandingView.vue'), + meta: { public: true }, + }, + { + path: '/login', + component: () => import('@/views/LoginView.vue'), + meta: { public: true }, + }, + { + path: '/admin', + component: () => import('@/layouts/AdminLayout.vue'), + children: [ + { path: '', component: () => import('@/views/DashboardView.vue') }, + { path: 'products', component: () => import('@/views/products/ProductListView.vue') }, + { path: 'products/create', component: () => import('@/views/products/ProductFormView.vue') }, + { path: 'products/:name', component: () => import('@/views/products/ProductDetailView.vue') }, + { path: 'products/:name/edit', component: () => import('@/views/products/ProductFormView.vue') }, + ], + }, + ], +}); + +let sessionChecked = false; + +router.beforeEach(async (to) => { + const auth = useAuthStore(); + + // Check session once on first navigation (handles page refresh) + if (!sessionChecked) { + sessionChecked = true; + await auth.checkSession(); + } + + if (!to.meta.public && !auth.isAuthenticated) { + return '/login'; + } + if (to.path === '/login' && auth.isAuthenticated) { + return '/admin'; + } +}); diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductDetailView.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductDetailView.vue index d274a8d..15f6438 100644 --- a/src/Cocoar.Shelf.Client/src/views/products/ProductDetailView.vue +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductDetailView.vue @@ -1,281 +1,277 @@ - - - - - + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue index 2cf07f7..73a814c 100644 --- a/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue @@ -1,177 +1,172 @@ - - - - - + + + + + diff --git a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs index f55f9f9..f112af8 100644 --- a/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs +++ b/src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs @@ -1,432 +1,480 @@ -using System.Text.RegularExpressions; -using Cocoar.Shelf.Models; -using Cocoar.Shelf.Services; -using Microsoft.AspNetCore.Http.Features; - -namespace Cocoar.Shelf.Endpoints; - -public static partial class ApiEndpoints -{ - private static readonly Regex ProductNameRegex = new("^[a-z0-9][a-z0-9-]*$", RegexOptions.Compiled); - private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) { "admin", "api" }; - - public static WebApplication MapApiEndpoints(this WebApplication app) - { - var api = app.MapGroup("/_api"); - - // Auth endpoints (cookie-based) - api.MapAuthEndpoints(); - - // Public read endpoints - api.MapGet("/products", GetProducts); - api.MapGet("/products/{product}/versions", GetVersions); - - // Protected write endpoints (cookie or Bearer API key) - api.MapPost("/products", CreateProduct) - .AddEndpointFilter(); - api.MapPut("/products/{product}", UpdateProduct) - .AddEndpointFilter(); - api.MapDelete("/products/{product}", DeleteProduct) - .AddEndpointFilter(); - api.MapPost("/products/{product}/versions/{version}", UploadVersion) - .AddEndpointFilter(); - api.MapDelete("/products/{product}/versions/{version}", DeleteVersion) - .AddEndpointFilter(); - - return app; - } - - private static IResult GetProducts( - IProductConfigService configService, - IManifestService manifestService, - ILoggerFactory loggerFactory) - { - var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); - try - { - var products = configService.GetAll().Select(config => - { - var manifest = manifestService.GetManifest(config.Name); - return new - { - config.Name, - config.DisplayName, - config.Description, - config.Source, - config.Visibility, - Latest = manifest?.Latest, - Versions = manifest?.Versions ?? (IReadOnlyList)[] - }; - }); - - return Results.Ok(products); - } - catch (Exception ex) - { - LogListProductsFailed(logger, ex); - return Results.Json(new { error = "Failed to list products" }, statusCode: 500); - } - } - - private static IResult GetVersions( - string product, - IProductConfigService configService, - IManifestService manifestService, - ILoggerFactory loggerFactory) - { - var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); - try - { - var config = configService.GetConfig(product); - if (config == null) - { - LogProductNotRegistered(logger, product); - return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); - } - - var manifest = manifestService.GetManifest(product); - - return Results.Ok(new - { - Name = product, - Latest = manifest?.Latest, - Versions = manifest?.Versions ?? (IReadOnlyList)[] - }); - } - catch (Exception ex) - { - LogGetVersionsFailed(logger, product, ex); - return Results.Json(new { error = $"Failed to get versions for product '{product}'" }, statusCode: 500); - } - } - - private static async Task UploadVersion( - string product, - string version, - HttpContext httpContext, - IProductConfigService configService, - IUploadService uploadService, - ShelfOptions options, - ILoggerFactory loggerFactory, - CancellationToken ct) - { - var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); - try - { - return await UploadVersionCore(product, version, httpContext, configService, uploadService, options, logger, ct); - } - catch (BadHttpRequestException ex) - { - LogUploadBadRequest(logger, product, version, ex); - return Results.Json(new { error = $"Bad request: {ex.Message}" }, statusCode: 400); - } - catch (OperationCanceledException) - { - LogUploadCancelled(logger, product, version); - return Results.Json(new { error = "Upload cancelled" }, statusCode: 499); - } - catch (Exception ex) - { - LogUploadFailed(logger, product, version, ex); - return Results.Json(new { error = $"Upload failed: {ex.Message}" }, statusCode: 500); - } - } - - private static async Task UploadVersionCore( - string product, - string version, - HttpContext httpContext, - IProductConfigService configService, - IUploadService uploadService, - ShelfOptions opts, - ILogger logger, - CancellationToken ct) - { - // Increase request body size limit for this endpoint - var maxSizeFeature = httpContext.Features.Get(); - if (maxSizeFeature is { IsReadOnly: false }) - maxSizeFeature.MaxRequestBodySize = opts.MaxUploadSizeBytes; - - // Check product is registered - var config = configService.GetConfig(product); - if (config == null) - { - LogUploadProductNotRegistered(logger, product); - return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); - } - - // Validate version format - if (string.IsNullOrWhiteSpace(version)) - { - LogUploadEmptyVersion(logger, product); - return Results.Json(new { error = "Version must not be empty" }, statusCode: 400); - } - - if (!Regex.IsMatch(version, opts.VersionPattern)) - { - LogUploadInvalidVersion(logger, version, product, opts.VersionPattern); - return Results.Json( - new { error = $"Invalid version format: '{version}'. Must match pattern: {opts.VersionPattern}" }, - statusCode: 400); - } - - // Check Content-Length if present - if (httpContext.Request.ContentLength > opts.MaxUploadSizeBytes) - { - LogUploadTooLarge(logger, httpContext.Request.ContentLength, opts.MaxUploadSizeBytes, product, version); - return Results.Json(new { error = $"Upload exceeds maximum allowed size ({opts.MaxUploadSizeBytes} bytes)" }, statusCode: 413); - } - - // Read body with size limit - using var ms = new MemoryStream(); - var buffer = new byte[81920]; - long totalRead = 0; - int bytesRead; - - while ((bytesRead = await httpContext.Request.Body.ReadAsync(buffer, ct)) > 0) - { - totalRead += bytesRead; - if (totalRead > opts.MaxUploadSizeBytes) - { - LogUploadBodyTooLarge(logger, opts.MaxUploadSizeBytes, product, version); - return Results.Json(new { error = $"Upload exceeds maximum allowed size ({opts.MaxUploadSizeBytes} bytes)" }, statusCode: 413); - } - ms.Write(buffer, 0, bytesRead); - } - - ms.Position = 0; - - LogUploadProcessing(logger, product, version, totalRead); - - var result = await uploadService.UploadVersionAsync(product, version, ms, ct); - - switch (result.Status) - { - case UploadStatus.Success: - return Results.Created($"{httpContext.Request.PathBase}/_api/products/{product}/versions/{version}", null); - - case UploadStatus.MissingIndexHtml: - LogUploadRejected(logger, product, version, "missing index.html"); - return Results.Json(new { error = result.Error ?? "Archive must contain an index.html at the root" }, statusCode: 400); - - case UploadStatus.VersionConflict: - LogUploadRejected(logger, product, version, "concurrent upload"); - return Results.Json(new { error = result.Error ?? "Upload for this version is already in progress" }, statusCode: 409); - - case UploadStatus.InvalidArchive: - LogUploadRejected(logger, product, version, result.Error ?? "invalid archive"); - return Results.Json(new { error = result.Error ?? "Invalid ZIP archive" }, statusCode: 400); - - default: - LogUploadUnexpectedStatus(logger, product, version, result.Status); - return Results.Json(new { error = result.Error ?? "Internal error" }, statusCode: 500); - } - } - - private static async Task CreateProduct( - CreateProductRequest request, - IProductConfigService configService, - ILoggerFactory loggerFactory) - { - var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); - try - { - if (string.IsNullOrWhiteSpace(request.Name)) - return Results.Json(new { error = "Product name is required" }, statusCode: 400); - - if (!ProductNameRegex.IsMatch(request.Name)) - return Results.Json(new { error = "Product name must contain only lowercase letters, numbers, and hyphens" }, statusCode: 400); - - if (ReservedNames.Contains(request.Name)) - return Results.Json(new { error = $"'{request.Name}' is a reserved name" }, statusCode: 400); - - if (configService.GetConfig(request.Name) != null) - { - LogProductAlreadyExists(logger, request.Name); - return Results.Json(new { error = $"Product '{request.Name}' already exists" }, statusCode: 409); - } - - var config = new ProductConfig - { - Name = request.Name, - DisplayName = request.DisplayName, - Description = request.Description, - Source = request.Source ?? "upload", - Visibility = request.Visibility ?? "public" - }; - - await configService.CreateAsync(config); - LogProductCreated(logger, request.Name); - return Results.Created($"/_api/products/{request.Name}", config); - } - catch (Exception ex) - { - LogProductOperationFailed(logger, "create", request.Name, ex); - return Results.Json(new { error = $"Failed to create product: {ex.Message}" }, statusCode: 500); - } - } - - private static async Task UpdateProduct( - string product, - UpdateProductRequest request, - IProductConfigService configService, - ILoggerFactory loggerFactory) - { - var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); - try - { - var existing = configService.GetConfig(product); - if (existing == null) - { - LogProductNotRegistered(logger, product); - return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); - } - - var config = new ProductConfig - { - Name = product, - DisplayName = request.DisplayName ?? existing.DisplayName, - Description = request.Description ?? existing.Description, - Source = request.Source ?? existing.Source, - Visibility = request.Visibility ?? existing.Visibility - }; - - await configService.UpdateAsync(config); - LogProductUpdated(logger, product); - return Results.Ok(config); - } - catch (Exception ex) - { - LogProductOperationFailed(logger, "update", product, ex); - return Results.Json(new { error = $"Failed to update product: {ex.Message}" }, statusCode: 500); - } - } - - private static async Task DeleteProduct( - string product, - IProductConfigService configService, - IUploadService uploadService, - ILoggerFactory loggerFactory, - bool deleteData = false) - { - var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); - try - { - var existing = configService.GetConfig(product); - if (existing == null) - { - LogProductNotRegistered(logger, product); - return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); - } - - await configService.DeleteAsync(product); - LogProductDeleted(logger, product, deleteData); - - return Results.NoContent(); - } - catch (Exception ex) - { - LogProductOperationFailed(logger, "delete", product, ex); - return Results.Json(new { error = $"Failed to delete product: {ex.Message}" }, statusCode: 500); - } - } - - private static async Task DeleteVersion( - string product, - string version, - IProductConfigService configService, - IUploadService uploadService, - ShelfOptions options, - ILoggerFactory loggerFactory, - CancellationToken ct) - { - var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); - try - { - var config = configService.GetConfig(product); - if (config == null) - { - LogProductNotRegistered(logger, product); - return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); - } - - if (!Regex.IsMatch(version, options.VersionPattern)) - { - LogUploadInvalidVersion(logger, version, product, options.VersionPattern); - return Results.Json(new { error = $"Invalid version format: '{version}'" }, statusCode: 400); - } - - var deleted = await uploadService.DeleteVersionAsync(product, version, ct); - if (!deleted) - return Results.Json(new { error = $"Version '{version}' not found for product '{product}'" }, statusCode: 404); - - LogVersionDeleted(logger, product, version); - return Results.NoContent(); - } - catch (Exception ex) - { - LogProductOperationFailed(logger, "delete version", $"{product}/{version}", ex); - return Results.Json(new { error = $"Failed to delete version: {ex.Message}" }, statusCode: 500); - } - } - - [LoggerMessage(Level = LogLevel.Error, Message = "Failed to list products")] - private static partial void LogListProductsFailed(ILogger logger, Exception ex); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Product not registered: {Product}")] - private static partial void LogProductNotRegistered(ILogger logger, string product); - - [LoggerMessage(Level = LogLevel.Error, Message = "Failed to get versions for product {Product}")] - private static partial void LogGetVersionsFailed(ILogger logger, string product, Exception ex); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Upload bad request for {Product}/{Version}")] - private static partial void LogUploadBadRequest(ILogger logger, string product, string version, Exception ex); - - [LoggerMessage(Level = LogLevel.Information, Message = "Upload cancelled for {Product}/{Version}")] - private static partial void LogUploadCancelled(ILogger logger, string product, string version); - - [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed for {Product}/{Version}")] - private static partial void LogUploadFailed(ILogger logger, string product, string version, Exception ex); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: product {Product} is not registered")] - private static partial void LogUploadProductNotRegistered(ILogger logger, string product); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: empty version for product {Product}")] - private static partial void LogUploadEmptyVersion(ILogger logger, string product); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: invalid version format {Version} for product {Product} (pattern: {Pattern})")] - private static partial void LogUploadInvalidVersion(ILogger logger, string version, string product, string pattern); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: content length {Length} exceeds limit {Limit} for {Product}/{Version}")] - private static partial void LogUploadTooLarge(ILogger logger, long? length, long limit, string product, string version); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: body exceeds limit {Limit} for {Product}/{Version}")] - private static partial void LogUploadBodyTooLarge(ILogger logger, long limit, string product, string version); - - [LoggerMessage(Level = LogLevel.Information, Message = "Processing upload for {Product}/{Version} ({Bytes} bytes)")] - private static partial void LogUploadProcessing(ILogger logger, string product, string version, long bytes); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected for {Product}/{Version}: {Reason}")] - private static partial void LogUploadRejected(ILogger logger, string product, string version, string reason); - - [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed with unexpected status {Status} for {Product}/{Version}")] - private static partial void LogUploadUnexpectedStatus(ILogger logger, string product, string version, UploadStatus status); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Product already exists: {Product}")] - private static partial void LogProductAlreadyExists(ILogger logger, string product); - - [LoggerMessage(Level = LogLevel.Information, Message = "Product created: {Product}")] - private static partial void LogProductCreated(ILogger logger, string product); - - [LoggerMessage(Level = LogLevel.Information, Message = "Product updated: {Product}")] - private static partial void LogProductUpdated(ILogger logger, string product); - - [LoggerMessage(Level = LogLevel.Information, Message = "Product deleted: {Product} (deleteData={DeleteData})")] - private static partial void LogProductDeleted(ILogger logger, string product, bool deleteData); - - [LoggerMessage(Level = LogLevel.Information, Message = "Version deleted: {Product}/{Version}")] - private static partial void LogVersionDeleted(ILogger logger, string product, string version); - - [LoggerMessage(Level = LogLevel.Error, Message = "Failed to {Operation} product {Product}")] - private static partial void LogProductOperationFailed(ILogger logger, string operation, string product, Exception ex); -} +using System.Text.RegularExpressions; +using Cocoar.Shelf.Models; +using Cocoar.Shelf.Services; +using Microsoft.AspNetCore.Http.Features; + +namespace Cocoar.Shelf.Endpoints; + +public static partial class ApiEndpoints +{ + private static readonly Regex ProductNameRegex = new("^[a-z0-9][a-z0-9-]*$", RegexOptions.Compiled); + private static readonly HashSet ReservedNames = new(StringComparer.OrdinalIgnoreCase) { "admin", "api" }; + + public static WebApplication MapApiEndpoints(this WebApplication app) + { + var api = app.MapGroup("/_api"); + + // Auth endpoints (cookie-based) + api.MapAuthEndpoints(); + + // Public read endpoints + api.MapGet("/products", GetProducts); + api.MapGet("/products/{product}", GetProduct); + api.MapGet("/products/{product}/versions", GetVersions); + api.MapGet("/shelf-config", GetShelfConfig); + + // Protected write endpoints (cookie or Bearer API key) + api.MapPost("/products", CreateProduct) + .AddEndpointFilter(); + api.MapPut("/products/{product}", UpdateProduct) + .AddEndpointFilter(); + api.MapDelete("/products/{product}", DeleteProduct) + .AddEndpointFilter(); + api.MapPost("/products/{product}/versions/{version}", UploadVersion) + .AddEndpointFilter(); + api.MapDelete("/products/{product}/versions/{version}", DeleteVersion) + .AddEndpointFilter(); + + return app; + } + + private static IResult GetProducts( + IProductConfigService configService, + IManifestService manifestService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var products = configService.GetAll().Select(config => + { + var manifest = manifestService.GetManifest(config.Name); + return new + { + config.Name, + config.DisplayName, + config.Description, + config.Source, + config.Visibility, + Latest = manifest?.Latest, + Versions = manifest?.Versions ?? (IReadOnlyList)[] + }; + }); + + return Results.Ok(products); + } + catch (Exception ex) + { + LogListProductsFailed(logger, ex); + return Results.Json(new { error = "Failed to list products" }, statusCode: 500); + } + } + + private static IResult GetVersions( + string product, + IProductConfigService configService, + IManifestService manifestService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var config = configService.GetConfig(product); + if (config == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + var manifest = manifestService.GetManifest(product); + + return Results.Ok(new + { + Name = product, + Latest = manifest?.Latest, + Versions = manifest?.Versions ?? (IReadOnlyList)[] + }); + } + catch (Exception ex) + { + LogGetVersionsFailed(logger, product, ex); + return Results.Json(new { error = $"Failed to get versions for product '{product}'" }, statusCode: 500); + } + } + + private static IResult GetProduct( + string product, + IProductConfigService configService, + IManifestService manifestService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var config = configService.GetConfig(product); + if (config == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + var manifest = manifestService.GetManifest(product); + return Results.Ok(new + { + config.Name, + config.DisplayName, + config.Description, + config.Source, + config.Visibility, + Latest = manifest?.Latest, + Versions = manifest?.Versions ?? (IReadOnlyList)[] + }); + } + catch (Exception ex) + { + LogGetProductFailed(logger, product, ex); + return Results.Json(new { error = $"Failed to get product '{product}'" }, statusCode: 500); + } + } + + private static IResult GetShelfConfig(ShelfOptions options) => + Results.Ok(new { pathBase = options.PathBase }); + + private static async Task UploadVersion( + string product, + string version, + HttpContext httpContext, + IProductConfigService configService, + IUploadService uploadService, + ShelfOptions options, + ILoggerFactory loggerFactory, + CancellationToken ct) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + return await UploadVersionCore(product, version, httpContext, configService, uploadService, options, logger, ct); + } + catch (BadHttpRequestException ex) + { + LogUploadBadRequest(logger, product, version, ex); + return Results.Json(new { error = $"Bad request: {ex.Message}" }, statusCode: 400); + } + catch (OperationCanceledException) + { + LogUploadCancelled(logger, product, version); + return Results.Json(new { error = "Upload cancelled" }, statusCode: 499); + } + catch (Exception ex) + { + LogUploadFailed(logger, product, version, ex); + return Results.Json(new { error = $"Upload failed: {ex.Message}" }, statusCode: 500); + } + } + + private static async Task UploadVersionCore( + string product, + string version, + HttpContext httpContext, + IProductConfigService configService, + IUploadService uploadService, + ShelfOptions opts, + ILogger logger, + CancellationToken ct) + { + // Increase request body size limit for this endpoint + var maxSizeFeature = httpContext.Features.Get(); + if (maxSizeFeature is { IsReadOnly: false }) + maxSizeFeature.MaxRequestBodySize = opts.MaxUploadSizeBytes; + + // Check product is registered + var config = configService.GetConfig(product); + if (config == null) + { + LogUploadProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + // Validate version format + if (string.IsNullOrWhiteSpace(version)) + { + LogUploadEmptyVersion(logger, product); + return Results.Json(new { error = "Version must not be empty" }, statusCode: 400); + } + + if (!Regex.IsMatch(version, opts.VersionPattern)) + { + LogUploadInvalidVersion(logger, version, product, opts.VersionPattern); + return Results.Json( + new { error = $"Invalid version format: '{version}'. Must match pattern: {opts.VersionPattern}" }, + statusCode: 400); + } + + // Check Content-Length if present + if (httpContext.Request.ContentLength > opts.MaxUploadSizeBytes) + { + LogUploadTooLarge(logger, httpContext.Request.ContentLength, opts.MaxUploadSizeBytes, product, version); + return Results.Json(new { error = $"Upload exceeds maximum allowed size ({opts.MaxUploadSizeBytes} bytes)" }, statusCode: 413); + } + + // Read body with size limit + using var ms = new MemoryStream(); + var buffer = new byte[81920]; + long totalRead = 0; + int bytesRead; + + while ((bytesRead = await httpContext.Request.Body.ReadAsync(buffer, ct)) > 0) + { + totalRead += bytesRead; + if (totalRead > opts.MaxUploadSizeBytes) + { + LogUploadBodyTooLarge(logger, opts.MaxUploadSizeBytes, product, version); + return Results.Json(new { error = $"Upload exceeds maximum allowed size ({opts.MaxUploadSizeBytes} bytes)" }, statusCode: 413); + } + ms.Write(buffer, 0, bytesRead); + } + + ms.Position = 0; + + LogUploadProcessing(logger, product, version, totalRead); + + var result = await uploadService.UploadVersionAsync(product, version, ms, ct); + + switch (result.Status) + { + case UploadStatus.Success: + return Results.Created($"{httpContext.Request.PathBase}/_api/products/{product}/versions/{version}", null); + + case UploadStatus.MissingIndexHtml: + LogUploadRejected(logger, product, version, "missing index.html"); + return Results.Json(new { error = result.Error ?? "Archive must contain an index.html at the root" }, statusCode: 400); + + case UploadStatus.VersionConflict: + LogUploadRejected(logger, product, version, "concurrent upload"); + return Results.Json(new { error = result.Error ?? "Upload for this version is already in progress" }, statusCode: 409); + + case UploadStatus.InvalidArchive: + LogUploadRejected(logger, product, version, result.Error ?? "invalid archive"); + return Results.Json(new { error = result.Error ?? "Invalid ZIP archive" }, statusCode: 400); + + default: + LogUploadUnexpectedStatus(logger, product, version, result.Status); + return Results.Json(new { error = result.Error ?? "Internal error" }, statusCode: 500); + } + } + + private static async Task CreateProduct( + CreateProductRequest request, + IProductConfigService configService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + if (string.IsNullOrWhiteSpace(request.Name)) + return Results.Json(new { error = "Product name is required" }, statusCode: 400); + + if (!ProductNameRegex.IsMatch(request.Name)) + return Results.Json(new { error = "Product name must contain only lowercase letters, numbers, and hyphens" }, statusCode: 400); + + if (ReservedNames.Contains(request.Name)) + return Results.Json(new { error = $"'{request.Name}' is a reserved name" }, statusCode: 400); + + if (configService.GetConfig(request.Name) != null) + { + LogProductAlreadyExists(logger, request.Name); + return Results.Json(new { error = $"Product '{request.Name}' already exists" }, statusCode: 409); + } + + var config = new ProductConfig + { + Name = request.Name, + DisplayName = request.DisplayName, + Description = request.Description, + Source = request.Source ?? "upload", + Visibility = request.Visibility ?? "public" + }; + + await configService.CreateAsync(config); + LogProductCreated(logger, request.Name); + return Results.Created($"/_api/products/{request.Name}", config); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "create", request.Name, ex); + return Results.Json(new { error = $"Failed to create product: {ex.Message}" }, statusCode: 500); + } + } + + private static async Task UpdateProduct( + string product, + UpdateProductRequest request, + IProductConfigService configService, + ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var existing = configService.GetConfig(product); + if (existing == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + var config = new ProductConfig + { + Name = product, + DisplayName = request.DisplayName ?? existing.DisplayName, + Description = request.Description ?? existing.Description, + Source = request.Source ?? existing.Source, + Visibility = request.Visibility ?? existing.Visibility + }; + + await configService.UpdateAsync(config); + LogProductUpdated(logger, product); + return Results.Ok(config); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "update", product, ex); + return Results.Json(new { error = $"Failed to update product: {ex.Message}" }, statusCode: 500); + } + } + + private static async Task DeleteProduct( + string product, + IProductConfigService configService, + IUploadService uploadService, + ILoggerFactory loggerFactory, + CancellationToken ct, + bool deleteData = false) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var existing = configService.GetConfig(product); + if (existing == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + await configService.DeleteAsync(product); + + if (deleteData) + await uploadService.DeleteProductDataAsync(product, ct); + + LogProductDeleted(logger, product, deleteData); + + return Results.NoContent(); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "delete", product, ex); + return Results.Json(new { error = $"Failed to delete product: {ex.Message}" }, statusCode: 500); + } + } + + private static async Task DeleteVersion( + string product, + string version, + IProductConfigService configService, + IUploadService uploadService, + ShelfOptions options, + ILoggerFactory loggerFactory, + CancellationToken ct) + { + var logger = loggerFactory.CreateLogger("Cocoar.Shelf.Api"); + try + { + var config = configService.GetConfig(product); + if (config == null) + { + LogProductNotRegistered(logger, product); + return Results.Json(new { error = $"Product '{product}' is not registered" }, statusCode: 404); + } + + if (!Regex.IsMatch(version, options.VersionPattern)) + { + LogUploadInvalidVersion(logger, version, product, options.VersionPattern); + return Results.Json(new { error = $"Invalid version format: '{version}'" }, statusCode: 400); + } + + var deleted = await uploadService.DeleteVersionAsync(product, version, ct); + if (!deleted) + return Results.Json(new { error = $"Version '{version}' not found for product '{product}'" }, statusCode: 404); + + LogVersionDeleted(logger, product, version); + return Results.NoContent(); + } + catch (Exception ex) + { + LogProductOperationFailed(logger, "delete version", $"{product}/{version}", ex); + return Results.Json(new { error = $"Failed to delete version: {ex.Message}" }, statusCode: 500); + } + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to list products")] + private static partial void LogListProductsFailed(ILogger logger, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Product not registered: {Product}")] + private static partial void LogProductNotRegistered(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to get versions for product {Product}")] + private static partial void LogGetVersionsFailed(ILogger logger, string product, Exception ex); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to get product {Product}")] + private static partial void LogGetProductFailed(ILogger logger, string product, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload bad request for {Product}/{Version}")] + private static partial void LogUploadBadRequest(ILogger logger, string product, string version, Exception ex); + + [LoggerMessage(Level = LogLevel.Information, Message = "Upload cancelled for {Product}/{Version}")] + private static partial void LogUploadCancelled(ILogger logger, string product, string version); + + [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed for {Product}/{Version}")] + private static partial void LogUploadFailed(ILogger logger, string product, string version, Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: product {Product} is not registered")] + private static partial void LogUploadProductNotRegistered(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: empty version for product {Product}")] + private static partial void LogUploadEmptyVersion(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: invalid version format {Version} for product {Product} (pattern: {Pattern})")] + private static partial void LogUploadInvalidVersion(ILogger logger, string version, string product, string pattern); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: content length {Length} exceeds limit {Limit} for {Product}/{Version}")] + private static partial void LogUploadTooLarge(ILogger logger, long? length, long limit, string product, string version); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected: body exceeds limit {Limit} for {Product}/{Version}")] + private static partial void LogUploadBodyTooLarge(ILogger logger, long limit, string product, string version); + + [LoggerMessage(Level = LogLevel.Information, Message = "Processing upload for {Product}/{Version} ({Bytes} bytes)")] + private static partial void LogUploadProcessing(ILogger logger, string product, string version, long bytes); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Upload rejected for {Product}/{Version}: {Reason}")] + private static partial void LogUploadRejected(ILogger logger, string product, string version, string reason); + + [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed with unexpected status {Status} for {Product}/{Version}")] + private static partial void LogUploadUnexpectedStatus(ILogger logger, string product, string version, UploadStatus status); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Product already exists: {Product}")] + private static partial void LogProductAlreadyExists(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Information, Message = "Product created: {Product}")] + private static partial void LogProductCreated(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Information, Message = "Product updated: {Product}")] + private static partial void LogProductUpdated(ILogger logger, string product); + + [LoggerMessage(Level = LogLevel.Information, Message = "Product deleted: {Product} (deleteData={DeleteData})")] + private static partial void LogProductDeleted(ILogger logger, string product, bool deleteData); + + [LoggerMessage(Level = LogLevel.Information, Message = "Version deleted: {Product}/{Version}")] + private static partial void LogVersionDeleted(ILogger logger, string product, string version); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to {Operation} product {Product}")] + private static partial void LogProductOperationFailed(ILogger logger, string operation, string product, Exception ex); +} diff --git a/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs b/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs index 9cebdc7..d2d45ea 100644 --- a/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs +++ b/src/Cocoar.Shelf/Middleware/DocsRoutingMiddleware.cs @@ -1,157 +1,164 @@ -using System.Text.RegularExpressions; -using Cocoar.Configuration.Reactive; -using Cocoar.Shelf.Services; -using Microsoft.AspNetCore.StaticFiles; - -namespace Cocoar.Shelf.Middleware; - -public partial class DocsRoutingMiddleware -{ - private readonly RequestDelegate _next; - private readonly IManifestService _manifestService; - private readonly BasePathDetector _basePathDetector; - private readonly IReactiveConfig _config; - private readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); - private readonly Regex _versionRegex; - - public DocsRoutingMiddleware( - RequestDelegate next, - IManifestService manifestService, - BasePathDetector basePathDetector, - IReactiveConfig config) - { - _next = next; - _manifestService = manifestService; - _basePathDetector = basePathDetector; - _config = config; - _versionRegex = new Regex(_config.CurrentValue.VersionPattern, RegexOptions.Compiled); - } - - public async Task InvokeAsync(HttpContext context) - { - if (context.Request.Method != HttpMethods.Get && context.Request.Method != HttpMethods.Head) - { - await _next(context); - return; - } - - var path = context.Request.Path.Value?.Trim('/') ?? ""; - - if (string.IsNullOrEmpty(path)) - { - await _next(context); - return; - } - - // Reserved prefixes — never interpret as product names - if (path.StartsWith('_')) - { - await _next(context); - return; - } - - var segments = path.Split('/', 2); - var product = segments[0]; - var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); - - if (!Directory.Exists(productDir)) - { - await _next(context); - return; - } - - // Mark this request as handled by docs routing (prevents SPA fallback) - context.Items["DocsRouted"] = true; - - var rest = segments.Length > 1 ? segments[1] : ""; - string resolvedPath; - string version; - - var restSegments = rest.Split('/', 2); - if (restSegments[0].Length > 0 && _versionRegex.IsMatch(restSegments[0])) - { - version = restSegments[0]; - resolvedPath = Path.Combine(productDir, rest); - } - else - { - // No explicit version — redirect to latest so the URL matches - // the VitePress base path and client-side routing works correctly - var manifest = _manifestService.GetManifest(product); - - if (manifest == null) - { - context.Response.StatusCode = 404; - return; - } - - var redirectPath = $"{context.Request.PathBase}/{product}/{manifest.Latest}/{rest}"; - context.Response.Redirect(redirectPath, permanent: false); - return; - } - - // Directory requests → serve index.html - // Note: Directory.Exists handles SemVer directories like "v0.1" where - // Path.GetExtension would incorrectly treat ".1" as a file extension - if (string.IsNullOrEmpty(Path.GetExtension(resolvedPath)) || Directory.Exists(resolvedPath)) - { - resolvedPath = Path.Combine(resolvedPath, "index.html"); - } - - resolvedPath = Path.GetFullPath(resolvedPath); - - // Security: prevent path traversal outside docs root - var docsRootFull = Path.GetFullPath(_config.CurrentValue.DocsRoot); - if (!resolvedPath.StartsWith(docsRootFull, StringComparison.OrdinalIgnoreCase)) - { - context.Response.StatusCode = 400; - return; - } - - if (!File.Exists(resolvedPath)) - { - context.Response.StatusCode = 404; - return; - } - - if (!_contentTypeProvider.TryGetContentType(resolvedPath, out var contentType)) - { - contentType = "application/octet-stream"; - } - - // Immutable caching for hashed assets (e.g. style.a1b2c3d4.css) - if (HashedAssetRegex().IsMatch(Path.GetFileName(resolvedPath))) - { - context.Response.Headers.CacheControl = "public, max-age=31536000, immutable"; - } - - context.Response.ContentType = contentType; - - // Rewrite base path in text-based responses - if (IsTextContent(contentType)) - { - var versionDir = Path.Combine(productDir, version); - var originalBase = _basePathDetector.Detect(versionDir); - var targetBase = $"{context.Request.PathBase}/{product}/{version}/"; - - if (originalBase != targetBase) - { - var content = await File.ReadAllTextAsync(resolvedPath); - var rewritten = BasePathRewriter.Rewrite(content, originalBase, targetBase, contentType); - await context.Response.WriteAsync(rewritten); - return; - } - } - - await context.Response.SendFileAsync(resolvedPath); - } - - private static bool IsTextContent(string contentType) => - contentType.Contains("text/html") || - contentType.Contains("text/css") || - contentType.Contains("application/javascript") || - contentType.Contains("text/javascript"); - - [GeneratedRegex(@"\.[a-f0-9]{6,}\.(css|js)$")] - private static partial Regex HashedAssetRegex(); -} +using System.Text.RegularExpressions; +using Cocoar.Configuration.Reactive; +using Cocoar.Shelf.Services; +using Microsoft.AspNetCore.StaticFiles; + +namespace Cocoar.Shelf.Middleware; + +public partial class DocsRoutingMiddleware +{ + private readonly RequestDelegate _next; + private readonly IManifestService _manifestService; + private readonly BasePathDetector _basePathDetector; + private readonly IReactiveConfig _config; + private readonly FileExtensionContentTypeProvider _contentTypeProvider = new(); + private (string Pattern, Regex Compiled) _versionRegexCache; + + public DocsRoutingMiddleware( + RequestDelegate next, + IManifestService manifestService, + BasePathDetector basePathDetector, + IReactiveConfig config) + { + _next = next; + _manifestService = manifestService; + _basePathDetector = basePathDetector; + _config = config; + var initialPattern = config.CurrentValue.VersionPattern; + _versionRegexCache = (initialPattern, new Regex(initialPattern, RegexOptions.Compiled)); + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Method != HttpMethods.Get && context.Request.Method != HttpMethods.Head) + { + await _next(context); + return; + } + + var path = context.Request.Path.Value?.Trim('/') ?? ""; + + if (string.IsNullOrEmpty(path)) + { + await _next(context); + return; + } + + // Reserved prefixes — never interpret as product names + if (path.StartsWith('_')) + { + await _next(context); + return; + } + + var segments = path.Split('/', 2); + var product = segments[0]; + var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); + + if (!Directory.Exists(productDir)) + { + await _next(context); + return; + } + + var rest = segments.Length > 1 ? segments[1] : ""; + string resolvedPath; + string version; + + var restSegments = rest.Split('/', 2); + if (restSegments[0].Length > 0 && GetVersionRegex().IsMatch(restSegments[0])) + { + version = restSegments[0]; + resolvedPath = Path.Combine(productDir, rest); + } + else + { + // No explicit version — redirect to latest so the URL matches + // the VitePress base path and client-side routing works correctly + var manifest = _manifestService.GetManifest(product); + + if (manifest == null) + { + context.Response.StatusCode = 404; + return; + } + + var redirectPath = $"{context.Request.PathBase}/{product}/{manifest.Latest}/{rest}"; + context.Response.Redirect(redirectPath, permanent: false); + return; + } + + // Directory requests → serve index.html + // Note: Directory.Exists handles SemVer directories like "v0.1" where + // Path.GetExtension would incorrectly treat ".1" as a file extension + if (string.IsNullOrEmpty(Path.GetExtension(resolvedPath)) || Directory.Exists(resolvedPath)) + { + resolvedPath = Path.Combine(resolvedPath, "index.html"); + } + + resolvedPath = Path.GetFullPath(resolvedPath); + + // Security: prevent path traversal outside docs root + var docsRootFull = Path.GetFullPath(_config.CurrentValue.DocsRoot); + if (!resolvedPath.StartsWith(docsRootFull, StringComparison.OrdinalIgnoreCase)) + { + context.Response.StatusCode = 400; + return; + } + + if (!File.Exists(resolvedPath)) + { + context.Response.StatusCode = 404; + return; + } + + if (!_contentTypeProvider.TryGetContentType(resolvedPath, out var contentType)) + { + contentType = "application/octet-stream"; + } + + // Immutable caching for hashed assets (e.g. style.a1b2c3d4.css) + if (HashedAssetRegex().IsMatch(Path.GetFileName(resolvedPath))) + { + context.Response.Headers.CacheControl = "public, max-age=31536000, immutable"; + } + + context.Response.ContentType = contentType; + + // Rewrite base path in text-based responses + if (IsTextContent(contentType)) + { + var versionDir = Path.Combine(productDir, version); + var originalBase = _basePathDetector.Detect(versionDir); + var targetBase = $"{context.Request.PathBase}/{product}/{version}/"; + + if (originalBase != targetBase) + { + var content = await File.ReadAllTextAsync(resolvedPath); + var rewritten = BasePathRewriter.Rewrite(content, originalBase, targetBase, contentType); + await context.Response.WriteAsync(rewritten); + return; + } + } + + await context.Response.SendFileAsync(resolvedPath); + } + + private static bool IsTextContent(string contentType) => + contentType.Contains("text/html") || + contentType.Contains("text/css") || + contentType.Contains("application/javascript") || + contentType.Contains("text/javascript"); + + private Regex GetVersionRegex() + { + var pattern = _config.CurrentValue.VersionPattern; + if (pattern == _versionRegexCache.Pattern) return _versionRegexCache.Compiled; + var compiled = new Regex(pattern, RegexOptions.Compiled); + _versionRegexCache = (pattern, compiled); + return compiled; + } + + [GeneratedRegex(@"\.[a-f0-9]{6,}\.(css|js)$")] + private static partial Regex HashedAssetRegex(); +} diff --git a/src/Cocoar.Shelf/Program.cs b/src/Cocoar.Shelf/Program.cs index f7cfa88..c464903 100644 --- a/src/Cocoar.Shelf/Program.cs +++ b/src/Cocoar.Shelf/Program.cs @@ -1,83 +1,109 @@ -using Cocoar.Configuration.AspNetCore; -using Cocoar.Configuration.DI.Extensions; -using Cocoar.Configuration.Providers; -using Cocoar.Shelf; -using Cocoar.Shelf.Endpoints; -using Cocoar.Shelf.Middleware; -using Cocoar.Shelf.Services; -using System.Globalization; -using Microsoft.AspNetCore.Authentication.Cookies; -using Serilog; -using Serilog.Sinks.SystemConsole.Themes; - -Log.Logger = new LoggerConfiguration() - .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) - .CreateBootstrapLogger(); - -var builder = WebApplication.CreateBuilder(args); - -builder.AddCocoarConfiguration(c => c - .UseConfiguration(rules => [ - rules.For().FromFile("data/configuration.json"), - rules.For().FromEnvironment("Shelf__"), - ])); - -var configManager = builder.GetCocoarConfigManager(); -var config = configManager.GetConfig()!; - -builder.Services.AddSerilog(logConfig => -{ - foreach (var (key, level) in config.Logging.LogLevels) - { - if (key.Equals("default", StringComparison.OrdinalIgnoreCase) || - key.Equals("*", StringComparison.OrdinalIgnoreCase)) - { - logConfig.MinimumLevel.Is(level); - } - else - { - logConfig.MinimumLevel.Override(key, level); - } - } - - logConfig.WriteTo.Console(theme: AnsiConsoleTheme.Code, formatProvider: CultureInfo.InvariantCulture); -}); - -builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => - { - options.Cookie.Name = "shelf.auth"; - options.Cookie.HttpOnly = true; - options.Cookie.SameSite = SameSiteMode.Strict; - options.ExpireTimeSpan = TimeSpan.FromHours(12); - options.SlidingExpiration = true; - options.Events.OnRedirectToLogin = context => - { - context.Response.StatusCode = 401; - return Task.CompletedTask; - }; - }); -builder.Services.AddAuthorization(); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -var app = builder.Build(); - -if (!string.IsNullOrEmpty(config.PathBase)) - app.UsePathBase(config.PathBase); - -app.UseSerilogRequestLogging(); -app.UseStaticFiles(); -app.UseAuthentication(); -app.UseAuthorization(); -app.MapApiEndpoints(); -app.MapLlmsTxt(); -app.UseMiddleware(); -app.MapFallbackToFile("index.html"); - -app.Run(config.AppUrl); - -public partial class Program; +using Cocoar.Configuration.AspNetCore; +using Cocoar.Configuration.DI.Extensions; +using Cocoar.Configuration.Providers; +using Cocoar.Configuration.Reactive; +using Cocoar.Shelf; +using Cocoar.Shelf.Endpoints; +using Cocoar.Shelf.Middleware; +using Cocoar.Shelf.Services; +using System.Globalization; +using Microsoft.AspNetCore.Authentication.Cookies; +using Serilog; +using Serilog.Sinks.SystemConsole.Themes; + +Log.Logger = new LoggerConfiguration() + .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) + .CreateBootstrapLogger(); + +var builder = WebApplication.CreateBuilder(args); + +builder.AddCocoarConfiguration(c => c + .UseConfiguration(rules => [ + rules.For().FromFile("data/configuration.json"), + rules.For().FromEnvironment("Shelf__"), + ])); + +var configManager = builder.GetCocoarConfigManager(); +var config = configManager.GetConfig()!; + +builder.Services.AddSerilog(logConfig => +{ + foreach (var (key, level) in config.Logging.LogLevels) + { + if (key.Equals("default", StringComparison.OrdinalIgnoreCase) || + key.Equals("*", StringComparison.OrdinalIgnoreCase)) + { + logConfig.MinimumLevel.Is(level); + } + else + { + logConfig.MinimumLevel.Override(key, level); + } + } + + logConfig.WriteTo.Console(theme: AnsiConsoleTheme.Code, formatProvider: CultureInfo.InvariantCulture); +}); + +builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.Cookie.Name = "shelf.auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.ExpireTimeSpan = TimeSpan.FromHours(12); + options.SlidingExpiration = true; + options.Events.OnRedirectToLogin = context => + { + context.Response.StatusCode = 401; + return Task.CompletedTask; + }; + }); +builder.Services.AddAuthorization(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +if (!string.IsNullOrEmpty(config.PathBase)) + app.UsePathBase(config.PathBase); + +app.Use(async (context, next) => +{ + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["X-Frame-Options"] = "SAMEORIGIN"; + context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + context.Response.Headers["X-XSS-Protection"] = "0"; + await next(); +}); + +app.UseSerilogRequestLogging(); +app.UseStaticFiles(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapApiEndpoints(); +app.MapLlmsTxt(); +app.UseMiddleware(); +app.MapFallback(async (HttpContext ctx, IReactiveConfig shelfConfig) => +{ + var env = ctx.RequestServices.GetRequiredService(); + var indexPath = Path.Combine(env.WebRootPath, "index.html"); + if (!File.Exists(indexPath)) + { + ctx.Response.StatusCode = 404; + return; + } + var html = await File.ReadAllTextAsync(indexPath); + var pathBase = shelfConfig.CurrentValue.PathBase.TrimEnd('/'); + html = html.Replace( + "window.__SHELF_OPTIONS__ = {\"pathBase\":\"\"};", + $"window.__SHELF_OPTIONS__ = {{\"pathBase\":\"{pathBase}\"}};"); + ctx.Response.ContentType = "text/html; charset=utf-8"; + await ctx.Response.WriteAsync(html); +}); + +app.Run(config.AppUrl); + +public partial class Program; diff --git a/src/Cocoar.Shelf/Services/BasePathDetector.cs b/src/Cocoar.Shelf/Services/BasePathDetector.cs index 5f7ab5f..a0d6f54 100644 --- a/src/Cocoar.Shelf/Services/BasePathDetector.cs +++ b/src/Cocoar.Shelf/Services/BasePathDetector.cs @@ -1,46 +1,53 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; - -namespace Cocoar.Shelf.Services; - -public sealed partial class BasePathDetector -{ - private readonly ConcurrentDictionary _cache = new(); - - /// - /// Detects the base path used during the VitePress build by inspecting index.html. - /// Looks for the first href pointing to "assets/" and extracts the prefix. - /// - public string Detect(string versionDir) - { - var key = versionDir; - - if (_cache.TryGetValue(key, out var cached)) - return cached; - - var indexPath = Path.Combine(versionDir, "index.html"); - - if (!File.Exists(indexPath)) - { - _cache.TryAdd(key, "/"); - return "/"; - } - - var html = File.ReadAllText(indexPath); - - // Look for href="...assets/" — the part before "assets/" is the base path - var match = AssetHrefRegex().Match(html); - - var basePath = match.Success ? match.Groups[1].Value : "/"; - - _cache.TryAdd(key, basePath); - return basePath; - } - - public void InvalidateCache(string versionDir) => - _cache.TryRemove(versionDir, out _); - - // Matches href="/some/base/assets/ and captures the base part - [GeneratedRegex("""href="([^"]*?)assets/""")] - private static partial Regex AssetHrefRegex(); -} +using System.Collections.Concurrent; +using System.Text.RegularExpressions; + +namespace Cocoar.Shelf.Services; + +public sealed partial class BasePathDetector +{ + private readonly ConcurrentDictionary _cache = new(); + + /// + /// Detects the base path used during the VitePress build by inspecting index.html. + /// Looks for the first href pointing to "assets/" and extracts the prefix. + /// + public string Detect(string versionDir) + { + var key = versionDir; + + if (_cache.TryGetValue(key, out var cached)) + return cached; + + var indexPath = Path.Combine(versionDir, "index.html"); + + if (!File.Exists(indexPath)) + { + _cache.TryAdd(key, "/"); + return "/"; + } + + var html = File.ReadAllText(indexPath); + + // Look for href="...assets/" — the part before "assets/" is the base path + var match = AssetHrefRegex().Match(html); + + var basePath = match.Success ? match.Groups[1].Value : "/"; + + _cache.TryAdd(key, basePath); + return basePath; + } + + public void InvalidateCache(string versionDir) => + _cache.TryRemove(versionDir, out _); + + public void InvalidateProductCache(string productDir) + { + foreach (var key in _cache.Keys) + if (key.StartsWith(productDir, StringComparison.OrdinalIgnoreCase)) + _cache.TryRemove(key, out _); + } + + // Matches href="/some/base/assets/ and captures the base part + [GeneratedRegex("""href="([^"]*?)assets/""")] + private static partial Regex AssetHrefRegex(); +} diff --git a/src/Cocoar.Shelf/Services/IUploadService.cs b/src/Cocoar.Shelf/Services/IUploadService.cs index f43d395..03c4c53 100644 --- a/src/Cocoar.Shelf/Services/IUploadService.cs +++ b/src/Cocoar.Shelf/Services/IUploadService.cs @@ -1,18 +1,20 @@ -namespace Cocoar.Shelf.Services; - -public interface IUploadService -{ - Task UploadVersionAsync(string product, string version, Stream zipStream, CancellationToken ct = default); - - Task DeleteVersionAsync(string product, string version, CancellationToken ct = default); -} - -public enum UploadStatus -{ - Success, - InvalidArchive, - MissingIndexHtml, - VersionConflict -} - -public record UploadResult(UploadStatus Status, string? Error = null); +namespace Cocoar.Shelf.Services; + +public interface IUploadService +{ + Task UploadVersionAsync(string product, string version, Stream zipStream, CancellationToken ct = default); + + Task DeleteVersionAsync(string product, string version, CancellationToken ct = default); + + Task DeleteProductDataAsync(string product, CancellationToken ct = default); +} + +public enum UploadStatus +{ + Success, + InvalidArchive, + MissingIndexHtml, + VersionConflict +} + +public record UploadResult(UploadStatus Status, string? Error = null); diff --git a/src/Cocoar.Shelf/Services/UploadService.cs b/src/Cocoar.Shelf/Services/UploadService.cs index d09d855..57f7724 100644 --- a/src/Cocoar.Shelf/Services/UploadService.cs +++ b/src/Cocoar.Shelf/Services/UploadService.cs @@ -1,139 +1,164 @@ -using System.Collections.Concurrent; -using System.IO.Compression; -using Cocoar.Configuration.Reactive; - -namespace Cocoar.Shelf.Services; - -public sealed partial class UploadService : IUploadService -{ - private readonly IReactiveConfig _config; - private readonly ILogger _logger; - private readonly ConcurrentDictionary _locks = new(); - - public UploadService(IReactiveConfig config, ILogger logger) - { - _config = config; - _logger = logger; - } - - public async Task UploadVersionAsync(string product, string version, Stream zipStream, CancellationToken ct = default) - { - var key = $"{product}/{version}"; - var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - - if (!await semaphore.WaitAsync(0, ct)) - return new UploadResult(UploadStatus.VersionConflict, "Upload for this version is already in progress"); - - var tempDir = Path.Combine(_config.CurrentValue.DocsRoot, ".shelf-tmp", Guid.NewGuid().ToString("N")); - - try - { - Directory.CreateDirectory(tempDir); - var tempDirFull = Path.GetFullPath(tempDir); - - // Extract ZIP - try - { - using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read); - - foreach (var entry in archive.Entries) - { - if (string.IsNullOrEmpty(entry.Name)) - continue; - - // Normalize backslashes from Windows-created ZIPs - var entryPath = entry.FullName.Replace('\\', '/'); - var destPath = Path.GetFullPath(Path.Combine(tempDir, entryPath)); - - // ZIP-Slip protection - if (!destPath.StartsWith(tempDirFull, StringComparison.OrdinalIgnoreCase)) - return new UploadResult(UploadStatus.InvalidArchive, "Archive contains path traversal entries"); - - var destDir = Path.GetDirectoryName(destPath); - if (destDir != null) - Directory.CreateDirectory(destDir); - - entry.ExtractToFile(destPath, overwrite: true); - } - } - catch (InvalidDataException) - { - return new UploadResult(UploadStatus.InvalidArchive, "Invalid or corrupt ZIP archive"); - } - - // Validate: index.html must exist at the root - if (!File.Exists(Path.Combine(tempDir, "index.html"))) - return new UploadResult(UploadStatus.MissingIndexHtml, "Archive must contain an index.html at the root"); - - // Ensure product directory exists - var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); - Directory.CreateDirectory(productDir); - - // Atomic move: swap existing version if present - var destVersionDir = Path.Combine(_config.CurrentValue.DocsRoot, product, version); - - if (Directory.Exists(destVersionDir)) - { - var oldDir = Path.Combine(_config.CurrentValue.DocsRoot, ".shelf-tmp", $"old-{Guid.NewGuid():N}"); - Directory.Move(destVersionDir, oldDir); - - try { Directory.Delete(oldDir, recursive: true); } - catch { /* best-effort cleanup */ } - } - - Directory.Move(tempDir, destVersionDir); - - LogVersionDeployed(product, version); - return new UploadResult(UploadStatus.Success); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - LogUploadFailed(product, version, ex); - return new UploadResult(UploadStatus.InvalidArchive, $"Upload failed: {ex.Message}"); - } - finally - { - semaphore.Release(); - - // Clean up temp dir if it still exists (failure path) - if (Directory.Exists(tempDir)) - { - try { Directory.Delete(tempDir, recursive: true); } - catch { /* best-effort cleanup */ } - } - } - } - - public async Task DeleteVersionAsync(string product, string version, CancellationToken ct = default) - { - var key = $"{product}/{version}"; - var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - - if (!await semaphore.WaitAsync(0, ct)) - return false; - - try - { - var versionDir = Path.Combine(_config.CurrentValue.DocsRoot, product, version); - if (!Directory.Exists(versionDir)) - return false; - - Directory.Delete(versionDir, recursive: true); - LogVersionDeleted(product, version); - return true; - } - finally - { - semaphore.Release(); - } - } - - [LoggerMessage(Level = LogLevel.Information, Message = "Version deployed: {Product}/{Version}")] - private partial void LogVersionDeployed(string product, string version); - - [LoggerMessage(Level = LogLevel.Information, Message = "Version deleted: {Product}/{Version}")] - private partial void LogVersionDeleted(string product, string version); - - [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed for {Product}/{Version}")] - private partial void LogUploadFailed(string product, string version, Exception ex); -} +using System.Collections.Concurrent; +using System.IO.Compression; +using Cocoar.Configuration.Reactive; + +namespace Cocoar.Shelf.Services; + +public sealed partial class UploadService : IUploadService +{ + private readonly IReactiveConfig _config; + private readonly ILogger _logger; + private readonly BasePathDetector _basePathDetector; + private readonly ConcurrentDictionary _locks = new(); + + public UploadService(IReactiveConfig config, ILogger logger, BasePathDetector basePathDetector) + { + _config = config; + _logger = logger; + _basePathDetector = basePathDetector; + } + + public async Task UploadVersionAsync(string product, string version, Stream zipStream, CancellationToken ct = default) + { + var key = $"{product}/{version}"; + var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + + if (!await semaphore.WaitAsync(0, ct)) + return new UploadResult(UploadStatus.VersionConflict, "Upload for this version is already in progress"); + + var tempDir = Path.Combine(_config.CurrentValue.DocsRoot, ".shelf-tmp", Guid.NewGuid().ToString("N")); + + try + { + Directory.CreateDirectory(tempDir); + var tempDirFull = Path.GetFullPath(tempDir); + + // Extract ZIP + try + { + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read); + + foreach (var entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) + continue; + + // Normalize backslashes from Windows-created ZIPs + var entryPath = entry.FullName.Replace('\\', '/'); + var destPath = Path.GetFullPath(Path.Combine(tempDir, entryPath)); + + // ZIP-Slip protection + if (!destPath.StartsWith(tempDirFull, StringComparison.OrdinalIgnoreCase)) + return new UploadResult(UploadStatus.InvalidArchive, "Archive contains path traversal entries"); + + var destDir = Path.GetDirectoryName(destPath); + if (destDir != null) + Directory.CreateDirectory(destDir); + + entry.ExtractToFile(destPath, overwrite: true); + } + } + catch (InvalidDataException) + { + return new UploadResult(UploadStatus.InvalidArchive, "Invalid or corrupt ZIP archive"); + } + + // Validate: index.html must exist at the root + if (!File.Exists(Path.Combine(tempDir, "index.html"))) + return new UploadResult(UploadStatus.MissingIndexHtml, "Archive must contain an index.html at the root"); + + // Ensure product directory exists + var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); + Directory.CreateDirectory(productDir); + + // Atomic move: swap existing version if present + var destVersionDir = Path.Combine(_config.CurrentValue.DocsRoot, product, version); + + if (Directory.Exists(destVersionDir)) + { + var oldDir = Path.Combine(_config.CurrentValue.DocsRoot, ".shelf-tmp", $"old-{Guid.NewGuid():N}"); + Directory.Move(destVersionDir, oldDir); + + try { Directory.Delete(oldDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + + Directory.Move(tempDir, destVersionDir); + + // Invalidate cached base path so a redeployed version picks up changes + _basePathDetector.InvalidateCache(destVersionDir); + + LogVersionDeployed(product, version); + return new UploadResult(UploadStatus.Success); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogUploadFailed(product, version, ex); + return new UploadResult(UploadStatus.InvalidArchive, $"Upload failed: {ex.Message}"); + } + finally + { + semaphore.Release(); + if (semaphore.CurrentCount == 1) + _locks.TryRemove(new KeyValuePair(key, semaphore)); + + // Clean up temp dir if it still exists (failure path) + if (Directory.Exists(tempDir)) + { + try { Directory.Delete(tempDir, recursive: true); } + catch { /* best-effort cleanup */ } + } + } + } + + public async Task DeleteVersionAsync(string product, string version, CancellationToken ct = default) + { + var key = $"{product}/{version}"; + var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + + if (!await semaphore.WaitAsync(0, ct)) + return false; + + try + { + var versionDir = Path.Combine(_config.CurrentValue.DocsRoot, product, version); + if (!Directory.Exists(versionDir)) + return false; + + _basePathDetector.InvalidateCache(versionDir); + Directory.Delete(versionDir, recursive: true); + LogVersionDeleted(product, version); + return true; + } + finally + { + semaphore.Release(); + if (semaphore.CurrentCount == 1) + _locks.TryRemove(new KeyValuePair(key, semaphore)); + } + } + + public Task DeleteProductDataAsync(string product, CancellationToken ct = default) + { + var productDir = Path.Combine(_config.CurrentValue.DocsRoot, product); + if (!Directory.Exists(productDir)) + return Task.FromResult(false); + + _basePathDetector.InvalidateProductCache(productDir); + Directory.Delete(productDir, recursive: true); + LogProductDataDeleted(product); + return Task.FromResult(true); + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Version deployed: {Product}/{Version}")] + private partial void LogVersionDeployed(string product, string version); + + [LoggerMessage(Level = LogLevel.Information, Message = "Version deleted: {Product}/{Version}")] + private partial void LogVersionDeleted(string product, string version); + + [LoggerMessage(Level = LogLevel.Information, Message = "Product data deleted: {Product}")] + private partial void LogProductDataDeleted(string product); + + [LoggerMessage(Level = LogLevel.Error, Message = "Upload failed for {Product}/{Version}")] + private partial void LogUploadFailed(string product, string version, Exception ex); +} diff --git a/src/tests/Cocoar.Shelf.Tests/UploadServiceDeleteTests.cs b/src/tests/Cocoar.Shelf.Tests/UploadServiceDeleteTests.cs index d9ff095..41a356b 100644 --- a/src/tests/Cocoar.Shelf.Tests/UploadServiceDeleteTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/UploadServiceDeleteTests.cs @@ -1,74 +1,74 @@ -using Cocoar.Shelf.Services; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Cocoar.Shelf.Tests; - -public sealed class UploadServiceDeleteTests : IDisposable -{ - private readonly string _docsRoot; - private readonly UploadService _sut; - - public UploadServiceDeleteTests() - { - _docsRoot = Path.Combine(Path.GetTempPath(), $"shelf-delete-tests-{Guid.NewGuid():N}"); - Directory.CreateDirectory(_docsRoot); - - var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = _docsRoot }); - _sut = new UploadService(config, NullLogger.Instance); - } - - [Fact] - public async Task DeleteVersion_RemovesDirectory_ReturnsTrue() - { - var versionDir = Path.Combine(_docsRoot, "myproduct", "v1"); - Directory.CreateDirectory(versionDir); - File.WriteAllText(Path.Combine(versionDir, "index.html"), ""); - - var result = await _sut.DeleteVersionAsync("myproduct", "v1"); - - Assert.True(result); - Assert.False(Directory.Exists(versionDir)); - } - - [Fact] - public async Task DeleteVersion_ReturnsFalse_WhenNotFound() - { - var result = await _sut.DeleteVersionAsync("myproduct", "v99"); - - Assert.False(result); - } - - [Fact] - public async Task DeleteVersion_PreservesOtherVersions() - { - Directory.CreateDirectory(Path.Combine(_docsRoot, "myproduct", "v1")); - Directory.CreateDirectory(Path.Combine(_docsRoot, "myproduct", "v2")); - - await _sut.DeleteVersionAsync("myproduct", "v1"); - - Assert.False(Directory.Exists(Path.Combine(_docsRoot, "myproduct", "v1"))); - Assert.True(Directory.Exists(Path.Combine(_docsRoot, "myproduct", "v2"))); - } - - [Fact] - public async Task DeleteVersion_RemovesAllContents() - { - var versionDir = Path.Combine(_docsRoot, "myproduct", "v3"); - Directory.CreateDirectory(Path.Combine(versionDir, "assets")); - Directory.CreateDirectory(Path.Combine(versionDir, "guide")); - File.WriteAllText(Path.Combine(versionDir, "index.html"), ""); - File.WriteAllText(Path.Combine(versionDir, "assets", "style.css"), "body{}"); - File.WriteAllText(Path.Combine(versionDir, "guide", "page.html"), ""); - - var result = await _sut.DeleteVersionAsync("myproduct", "v3"); - - Assert.True(result); - Assert.False(Directory.Exists(versionDir)); - } - - public void Dispose() - { - try { Directory.Delete(_docsRoot, recursive: true); } - catch { /* best-effort */ } - } -} +using Cocoar.Shelf.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cocoar.Shelf.Tests; + +public sealed class UploadServiceDeleteTests : IDisposable +{ + private readonly string _docsRoot; + private readonly UploadService _sut; + + public UploadServiceDeleteTests() + { + _docsRoot = Path.Combine(Path.GetTempPath(), $"shelf-delete-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_docsRoot); + + var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = _docsRoot }); + _sut = new UploadService(config, NullLogger.Instance, new BasePathDetector()); + } + + [Fact] + public async Task DeleteVersion_RemovesDirectory_ReturnsTrue() + { + var versionDir = Path.Combine(_docsRoot, "myproduct", "v1"); + Directory.CreateDirectory(versionDir); + File.WriteAllText(Path.Combine(versionDir, "index.html"), ""); + + var result = await _sut.DeleteVersionAsync("myproduct", "v1"); + + Assert.True(result); + Assert.False(Directory.Exists(versionDir)); + } + + [Fact] + public async Task DeleteVersion_ReturnsFalse_WhenNotFound() + { + var result = await _sut.DeleteVersionAsync("myproduct", "v99"); + + Assert.False(result); + } + + [Fact] + public async Task DeleteVersion_PreservesOtherVersions() + { + Directory.CreateDirectory(Path.Combine(_docsRoot, "myproduct", "v1")); + Directory.CreateDirectory(Path.Combine(_docsRoot, "myproduct", "v2")); + + await _sut.DeleteVersionAsync("myproduct", "v1"); + + Assert.False(Directory.Exists(Path.Combine(_docsRoot, "myproduct", "v1"))); + Assert.True(Directory.Exists(Path.Combine(_docsRoot, "myproduct", "v2"))); + } + + [Fact] + public async Task DeleteVersion_RemovesAllContents() + { + var versionDir = Path.Combine(_docsRoot, "myproduct", "v3"); + Directory.CreateDirectory(Path.Combine(versionDir, "assets")); + Directory.CreateDirectory(Path.Combine(versionDir, "guide")); + File.WriteAllText(Path.Combine(versionDir, "index.html"), ""); + File.WriteAllText(Path.Combine(versionDir, "assets", "style.css"), "body{}"); + File.WriteAllText(Path.Combine(versionDir, "guide", "page.html"), ""); + + var result = await _sut.DeleteVersionAsync("myproduct", "v3"); + + Assert.True(result); + Assert.False(Directory.Exists(versionDir)); + } + + public void Dispose() + { + try { Directory.Delete(_docsRoot, recursive: true); } + catch { /* best-effort */ } + } +} diff --git a/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs b/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs index 84919c7..a8fdc68 100644 --- a/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs +++ b/src/tests/Cocoar.Shelf.Tests/UploadServiceTests.cs @@ -1,138 +1,138 @@ -using System.IO.Compression; -using Cocoar.Shelf.Services; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Cocoar.Shelf.Tests; - -public sealed class UploadServiceTests : IDisposable -{ - private readonly string _docsRoot; - private readonly UploadService _sut; - - public UploadServiceTests() - { - _docsRoot = Path.Combine(Path.GetTempPath(), $"shelf-upload-tests-{Guid.NewGuid():N}"); - Directory.CreateDirectory(_docsRoot); - - var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = _docsRoot }); - _sut = new UploadService(config, NullLogger.Instance); - } - - [Fact] - public async Task UploadVersion_ExtractsZipToCorrectLocation() - { - using var zip = CreateZip(("index.html", ""), ("assets/style.css", "body{}")); - - var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); - - Assert.Equal(UploadStatus.Success, result.Status); - Assert.True(File.Exists(Path.Combine(_docsRoot, "myproduct", "v1", "index.html"))); - Assert.True(File.Exists(Path.Combine(_docsRoot, "myproduct", "v1", "assets", "style.css"))); - } - - [Fact] - public async Task UploadVersion_ReturnsMissingIndexHtml_WhenNoIndexHtml() - { - using var zip = CreateZip(("readme.txt", "hello")); - - var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); - - Assert.Equal(UploadStatus.MissingIndexHtml, result.Status); - } - - [Fact] - public async Task UploadVersion_DetectsZipSlipAttack() - { - using var zip = CreateZipWithEntry("../../../etc/passwd", "pwned"); - - var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); - - Assert.Equal(UploadStatus.InvalidArchive, result.Status); - Assert.Contains("path traversal", result.Error, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public async Task UploadVersion_ReturnsInvalidArchive_WhenNotZip() - { - using var stream = new MemoryStream("not a zip file"u8.ToArray()); - - var result = await _sut.UploadVersionAsync("myproduct", "v1", stream); - - Assert.Equal(UploadStatus.InvalidArchive, result.Status); - } - - [Fact] - public async Task UploadVersion_CleansUpTempDirOnFailure() - { - using var stream = new MemoryStream("not a zip file"u8.ToArray()); - - await _sut.UploadVersionAsync("myproduct", "v1", stream); - - var tempDir = Path.Combine(_docsRoot, ".shelf-tmp"); - if (Directory.Exists(tempDir)) - { - var remaining = Directory.GetDirectories(tempDir); - Assert.Empty(remaining); - } - } - - [Fact] - public async Task UploadVersion_ReplacesExistingVersion() - { - using var zip1 = CreateZip(("index.html", "v1")); - await _sut.UploadVersionAsync("myproduct", "v1", zip1); - - using var zip2 = CreateZip(("index.html", "v1-updated")); - var result = await _sut.UploadVersionAsync("myproduct", "v1", zip2); - - Assert.Equal(UploadStatus.Success, result.Status); - var content = File.ReadAllText(Path.Combine(_docsRoot, "myproduct", "v1", "index.html")); - Assert.Equal("v1-updated", content); - } - - [Fact] - public async Task UploadVersion_CreatesProductDirectoryIfMissing() - { - using var zip = CreateZip(("index.html", "")); - - var result = await _sut.UploadVersionAsync("newproduct", "v1", zip); - - Assert.Equal(UploadStatus.Success, result.Status); - Assert.True(Directory.Exists(Path.Combine(_docsRoot, "newproduct", "v1"))); - } - - private static MemoryStream CreateZip(params (string name, string content)[] entries) - { - var ms = new MemoryStream(); - using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) - { - foreach (var (name, content) in entries) - { - var entry = archive.CreateEntry(name); - using var writer = new StreamWriter(entry.Open()); - writer.Write(content); - } - } - ms.Position = 0; - return ms; - } - - private static MemoryStream CreateZipWithEntry(string entryPath, string content) - { - var ms = new MemoryStream(); - using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) - { - var entry = archive.CreateEntry(entryPath); - using var writer = new StreamWriter(entry.Open()); - writer.Write(content); - } - ms.Position = 0; - return ms; - } - - public void Dispose() - { - try { Directory.Delete(_docsRoot, recursive: true); } - catch { /* cleanup best-effort */ } - } -} +using System.IO.Compression; +using Cocoar.Shelf.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Cocoar.Shelf.Tests; + +public sealed class UploadServiceTests : IDisposable +{ + private readonly string _docsRoot; + private readonly UploadService _sut; + + public UploadServiceTests() + { + _docsRoot = Path.Combine(Path.GetTempPath(), $"shelf-upload-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_docsRoot); + + var config = new TestReactiveConfig(new ShelfOptions { DocsRoot = _docsRoot }); + _sut = new UploadService(config, NullLogger.Instance, new BasePathDetector()); + } + + [Fact] + public async Task UploadVersion_ExtractsZipToCorrectLocation() + { + using var zip = CreateZip(("index.html", ""), ("assets/style.css", "body{}")); + + var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); + + Assert.Equal(UploadStatus.Success, result.Status); + Assert.True(File.Exists(Path.Combine(_docsRoot, "myproduct", "v1", "index.html"))); + Assert.True(File.Exists(Path.Combine(_docsRoot, "myproduct", "v1", "assets", "style.css"))); + } + + [Fact] + public async Task UploadVersion_ReturnsMissingIndexHtml_WhenNoIndexHtml() + { + using var zip = CreateZip(("readme.txt", "hello")); + + var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); + + Assert.Equal(UploadStatus.MissingIndexHtml, result.Status); + } + + [Fact] + public async Task UploadVersion_DetectsZipSlipAttack() + { + using var zip = CreateZipWithEntry("../../../etc/passwd", "pwned"); + + var result = await _sut.UploadVersionAsync("myproduct", "v1", zip); + + Assert.Equal(UploadStatus.InvalidArchive, result.Status); + Assert.Contains("path traversal", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task UploadVersion_ReturnsInvalidArchive_WhenNotZip() + { + using var stream = new MemoryStream("not a zip file"u8.ToArray()); + + var result = await _sut.UploadVersionAsync("myproduct", "v1", stream); + + Assert.Equal(UploadStatus.InvalidArchive, result.Status); + } + + [Fact] + public async Task UploadVersion_CleansUpTempDirOnFailure() + { + using var stream = new MemoryStream("not a zip file"u8.ToArray()); + + await _sut.UploadVersionAsync("myproduct", "v1", stream); + + var tempDir = Path.Combine(_docsRoot, ".shelf-tmp"); + if (Directory.Exists(tempDir)) + { + var remaining = Directory.GetDirectories(tempDir); + Assert.Empty(remaining); + } + } + + [Fact] + public async Task UploadVersion_ReplacesExistingVersion() + { + using var zip1 = CreateZip(("index.html", "v1")); + await _sut.UploadVersionAsync("myproduct", "v1", zip1); + + using var zip2 = CreateZip(("index.html", "v1-updated")); + var result = await _sut.UploadVersionAsync("myproduct", "v1", zip2); + + Assert.Equal(UploadStatus.Success, result.Status); + var content = File.ReadAllText(Path.Combine(_docsRoot, "myproduct", "v1", "index.html")); + Assert.Equal("v1-updated", content); + } + + [Fact] + public async Task UploadVersion_CreatesProductDirectoryIfMissing() + { + using var zip = CreateZip(("index.html", "")); + + var result = await _sut.UploadVersionAsync("newproduct", "v1", zip); + + Assert.Equal(UploadStatus.Success, result.Status); + Assert.True(Directory.Exists(Path.Combine(_docsRoot, "newproduct", "v1"))); + } + + private static MemoryStream CreateZip(params (string name, string content)[] entries) + { + var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + foreach (var (name, content) in entries) + { + var entry = archive.CreateEntry(name); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + } + ms.Position = 0; + return ms; + } + + private static MemoryStream CreateZipWithEntry(string entryPath, string content) + { + var ms = new MemoryStream(); + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry(entryPath); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + ms.Position = 0; + return ms; + } + + public void Dispose() + { + try { Directory.Delete(_docsRoot, recursive: true); } + catch { /* cleanup best-effort */ } + } +} From 657025029b37d9827012f4f78a97e368e540745c Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Mon, 23 Mar 2026 07:40:15 +0100 Subject: [PATCH 7/8] feat: enhance product management with tags and visibility options, add preferences store for user settings --- .../src/core/models/shelf.models.ts | 66 +- .../src/stores/preferences.store.ts | 49 ++ .../src/views/LandingView.vue | 657 +++++++++++------- .../src/views/products/ProductFormView.vue | 128 +++- src/Cocoar.Shelf/Endpoints/ApiEndpoints.cs | 15 +- src/Cocoar.Shelf/Models/ProductConfig.cs | 32 +- src/Cocoar.Shelf/Models/ProductRequests.cs | 10 +- 7 files changed, 652 insertions(+), 305 deletions(-) create mode 100644 src/Cocoar.Shelf.Client/src/stores/preferences.store.ts diff --git a/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts index baf2f2c..a9c3041 100644 --- a/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts +++ b/src/Cocoar.Shelf.Client/src/core/models/shelf.models.ts @@ -1,30 +1,36 @@ -export interface Product { - name: string; - displayName: string | null; - description: string | null; - source: string; - visibility: string; - latest: string | null; - versions: string[]; -} - -export interface ProductVersions { - name: string; - latest: string | null; - versions: string[]; -} - -export interface CreateProductRequest { - name: string; - displayName?: string; - description?: string; - source?: string; - visibility?: string; -} - -export interface UpdateProductRequest { - displayName?: string; - description?: string; - source?: string; - visibility?: string; -} +export interface Product { + name: string; + displayName: string | null; + description: string | null; + source: string; + visibility: string; + tags: string[]; + showWhenEmpty: boolean; + latest: string | null; + versions: string[]; +} + +export interface ProductVersions { + name: string; + latest: string | null; + versions: string[]; +} + +export interface CreateProductRequest { + name: string; + displayName?: string; + description?: string; + source?: string; + visibility?: string; + tags?: string[]; + showWhenEmpty?: boolean; +} + +export interface UpdateProductRequest { + displayName?: string; + description?: string; + source?: string; + visibility?: string; + tags?: string[]; + showWhenEmpty?: boolean; +} diff --git a/src/Cocoar.Shelf.Client/src/stores/preferences.store.ts b/src/Cocoar.Shelf.Client/src/stores/preferences.store.ts new file mode 100644 index 0000000..1e0b6f9 --- /dev/null +++ b/src/Cocoar.Shelf.Client/src/stores/preferences.store.ts @@ -0,0 +1,49 @@ +import { defineStore } from 'pinia'; +import { ref, watch } from 'vue'; + +const STORAGE_KEY = 'shelf:preferences'; + +interface Preferences { + showPreview: boolean; + selectedTags: string[]; +} + +function load(): Preferences { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return { ...defaults(), ...JSON.parse(raw) }; + } catch { /* ignore corrupt data */ } + return defaults(); +} + +function defaults(): Preferences { + return { showPreview: false, selectedTags: [] }; +} + +export const usePreferencesStore = defineStore('preferences', () => { + const saved = load(); + const showPreview = ref(saved.showPreview); + const selectedTags = ref(saved.selectedTags); + + function persist() { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ + showPreview: showPreview.value, + selectedTags: selectedTags.value, + })); + } + + watch(showPreview, persist); + watch(selectedTags, persist, { deep: true }); + + function toggleTag(tag: string) { + const idx = selectedTags.value.indexOf(tag); + if (idx === -1) selectedTags.value.push(tag); + else selectedTags.value.splice(idx, 1); + } + + function clearTags() { + selectedTags.value = []; + } + + return { showPreview, selectedTags, toggleTag, clearTags }; +}); diff --git a/src/Cocoar.Shelf.Client/src/views/LandingView.vue b/src/Cocoar.Shelf.Client/src/views/LandingView.vue index 5a62f39..1356db9 100644 --- a/src/Cocoar.Shelf.Client/src/views/LandingView.vue +++ b/src/Cocoar.Shelf.Client/src/views/LandingView.vue @@ -1,253 +1,404 @@ - - - - - + + + + + diff --git a/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue b/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue index 73a814c..09a129a 100644 --- a/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue +++ b/src/Cocoar.Shelf.Client/src/views/products/ProductFormView.vue @@ -46,6 +46,33 @@ placeholder="upload" hint="Deployment source type (e.g., "upload")" /> + +
+ + + +
+
+ + Add +
+
+ + {{ tag }} + + +
+

No tags yet. Tags help users filter documentation on the landing page.

@@ -56,7 +83,7 @@