Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Endpoints/OfflineKitEndpoints.cs

342 lines
12 KiB
C#

using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Scanner.Core.Configuration;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class OfflineKitEndpoints
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public static void MapOfflineKitEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var group = endpoints
.MapGroup("/api/offline-kit")
.WithTags("Offline Kit");
// Sprint 026: OFFLINE-012 - Legacy v1 alias for backward compatibility
var v1Group = endpoints
.MapGroup("/api/v1/offline-kit")
.WithTags("Offline Kit");
MapEndpointsToGroup(group, isLegacy: false);
MapEndpointsToGroup(v1Group, isLegacy: true);
}
private static void MapEndpointsToGroup(RouteGroupBuilder group, bool isLegacy)
{
var suffix = isLegacy ? ".v1" : "";
group.MapPost("/import", HandleImportAsync)
.WithName($"scanner.offline-kit.import{suffix}")
.RequireAuthorization(ScannerPolicies.OfflineKitImport)
.Produces<OfflineKitImportResponseTransport>(StatusCodes.Status202Accepted)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status422UnprocessableEntity);
group.MapGet("/status", HandleStatusAsync)
.WithName($"scanner.offline-kit.status{suffix}")
.RequireAuthorization(ScannerPolicies.OfflineKitStatusRead)
.Produces<OfflineKitStatusTransport>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
// Sprint 026: OFFLINE-011 - Manifest retrieval
group.MapGet("/manifest", HandleGetManifestAsync)
.WithName($"scanner.offline-kit.manifest{suffix}")
.RequireAuthorization(ScannerPolicies.OfflineKitManifestRead)
.Produces<OfflineKitManifestTransport>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status204NoContent)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
// Sprint 026: OFFLINE-011 - Bundle validation
group.MapPost("/validate", HandleValidateAsync)
.WithName($"scanner.offline-kit.validate{suffix}")
.RequireAuthorization(ScannerPolicies.OfflineKitValidate)
.Produces<OfflineKitValidationResult>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
}
private static async Task<IResult> HandleImportAsync(
HttpContext context,
HttpRequest request,
IOptionsMonitor<OfflineKitOptions> offlineKitOptions,
OfflineKitImportService importService,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(offlineKitOptions);
ArgumentNullException.ThrowIfNull(importService);
if (!offlineKitOptions.CurrentValue.Enabled)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Offline kit import is not enabled",
StatusCodes.Status404NotFound);
}
if (!request.HasFormContentType)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid offline kit import request",
StatusCodes.Status400BadRequest,
detail: "Request must be multipart/form-data.");
}
var form = await request.ReadFormAsync(cancellationToken).ConfigureAwait(false);
var metadataJson = form["metadata"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(metadataJson))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid offline kit import request",
StatusCodes.Status400BadRequest,
detail: "Missing 'metadata' form field.");
}
OfflineKitImportMetadata? metadata;
try
{
metadata = JsonSerializer.Deserialize<OfflineKitImportMetadata>(metadataJson, JsonOptions);
}
catch (JsonException ex)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid offline kit import request",
StatusCodes.Status400BadRequest,
detail: $"Failed to parse metadata JSON: {ex.Message}");
}
if (metadata is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid offline kit import request",
StatusCodes.Status400BadRequest,
detail: "Metadata payload is empty.");
}
var bundle = form.Files.GetFile("bundle");
if (bundle is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid offline kit import request",
StatusCodes.Status400BadRequest,
detail: "Missing 'bundle' file upload.");
}
var manifest = form.Files.GetFile("manifest");
var bundleSignature = form.Files.GetFile("bundleSignature");
var manifestSignature = form.Files.GetFile("manifestSignature");
var tenantId = ResolveTenant(context);
var actor = ResolveActor(context);
try
{
var response = await importService.ImportAsync(
new OfflineKitImportRequest(
tenantId,
actor,
metadata,
bundle,
manifest,
bundleSignature,
manifestSignature),
cancellationToken).ConfigureAwait(false);
return Results.Accepted("/api/offline-kit/status", response);
}
catch (OfflineKitImportException ex)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Offline kit import failed",
ex.StatusCode,
detail: ex.Message,
extensions: new Dictionary<string, object?>
{
["reason_code"] = ex.ReasonCode,
["notes"] = ex.Notes
});
}
}
private static async Task<IResult> HandleStatusAsync(
HttpContext context,
IOptionsMonitor<OfflineKitOptions> offlineKitOptions,
OfflineKitStateStore stateStore,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(offlineKitOptions);
ArgumentNullException.ThrowIfNull(stateStore);
if (!offlineKitOptions.CurrentValue.Enabled)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Offline kit status is not enabled",
StatusCodes.Status404NotFound);
}
var tenantId = ResolveTenant(context);
var status = await stateStore.LoadStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
return status is null
? Results.NoContent()
: Results.Ok(status);
}
private static string ResolveTenant(HttpContext context)
{
var tenant = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant);
if (!string.IsNullOrWhiteSpace(tenant))
{
return tenant.Trim();
}
if (context.Request.Headers.TryGetValue("X-Stella-Tenant", out var headerTenant))
{
var headerValue = headerTenant.ToString();
if (!string.IsNullOrWhiteSpace(headerValue))
{
return headerValue.Trim();
}
}
return "default";
}
private static string ResolveActor(HttpContext context)
{
var subject = context.User?.FindFirstValue(StellaOpsClaimTypes.Subject);
if (!string.IsNullOrWhiteSpace(subject))
{
return subject.Trim();
}
var clientId = context.User?.FindFirstValue(StellaOpsClaimTypes.ClientId);
if (!string.IsNullOrWhiteSpace(clientId))
{
return clientId.Trim();
}
return "anonymous";
}
// Sprint 026: OFFLINE-011 - Manifest retrieval handler
private static async Task<IResult> HandleGetManifestAsync(
HttpContext context,
IOptionsMonitor<OfflineKitOptions> offlineKitOptions,
OfflineKitManifestService manifestService,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(offlineKitOptions);
ArgumentNullException.ThrowIfNull(manifestService);
if (!offlineKitOptions.CurrentValue.Enabled)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Offline kit is not enabled",
StatusCodes.Status404NotFound);
}
var tenantId = ResolveTenant(context);
var manifest = await manifestService.GetManifestAsync(tenantId, cancellationToken).ConfigureAwait(false);
return manifest is null
? Results.NoContent()
: Results.Ok(manifest);
}
// Sprint 026: OFFLINE-011 - Bundle validation handler
private static async Task<IResult> HandleValidateAsync(
HttpContext context,
HttpRequest request,
IOptionsMonitor<OfflineKitOptions> offlineKitOptions,
OfflineKitManifestService manifestService,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(offlineKitOptions);
ArgumentNullException.ThrowIfNull(manifestService);
if (!offlineKitOptions.CurrentValue.Enabled)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Offline kit validation is not enabled",
StatusCodes.Status404NotFound);
}
OfflineKitValidationRequest? validationRequest;
try
{
validationRequest = await request.ReadFromJsonAsync<OfflineKitValidationRequest>(JsonOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid validation request",
StatusCodes.Status400BadRequest,
detail: $"Failed to parse request JSON: {ex.Message}");
}
if (validationRequest is null || string.IsNullOrWhiteSpace(validationRequest.ManifestJson))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid validation request",
StatusCodes.Status400BadRequest,
detail: "Request body with manifestJson is required.");
}
var result = manifestService.ValidateManifest(
validationRequest.ManifestJson,
validationRequest.Signature,
validationRequest.VerifyAssets);
return Results.Ok(result);
}
}