342 lines
12 KiB
C#
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);
|
|
}
|
|
}
|
|
|