278 lines
13 KiB
C#
278 lines
13 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using ItWorx.KnowledgeHub.Api.Authorization;
|
|
using ItWorx.KnowledgeHub.Api.Security;
|
|
using ItWorx.KnowledgeHub.Application.Identity;
|
|
using ItWorx.KnowledgeHub.Application.Jobs;
|
|
using ItWorx.KnowledgeHub.Application.Jobs.UseCases;
|
|
using ItWorx.KnowledgeHub.Contracts.Errors;
|
|
using ItWorx.KnowledgeHub.Contracts.Jobs;
|
|
using ItWorx.KnowledgeHub.Domain.Identity;
|
|
using ItWorx.KnowledgeHub.Domain.Jobs;
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace ItWorx.KnowledgeHub.Api.Endpoints;
|
|
|
|
/// <summary>Cookie-admin-only control surface for durable jobs. Mutations require CSRF;
|
|
/// event streams expose only the latest persisted safe job snapshot.</summary>
|
|
public static class JobsEndpoints
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
|
{
|
|
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
|
|
};
|
|
|
|
public static IEndpointRouteBuilder MapJobsEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var group = app.MapGroup("/api/v1/jobs")
|
|
.WithTags("Jobs")
|
|
.RequireAuthorization(AuthorizationPolicies.RequireAdministrator);
|
|
|
|
group.MapPost("/course-tagging", QueueCourseTaggingAsync)
|
|
.RequireRateLimiting(RateLimitPolicies.JobCreation)
|
|
.AddEndpointFilter<StrictAntiforgeryEndpointFilter>();
|
|
group.MapPost("/backups", QueueBackupAsync)
|
|
.RequireRateLimiting(RateLimitPolicies.JobCreation)
|
|
.AddEndpointFilter<StrictAntiforgeryEndpointFilter>();
|
|
group.MapGet("", ListAsync);
|
|
group.MapGet("/{jobId:guid}", GetAsync);
|
|
group.MapPost("/{jobId:guid}/cancel", CancelAsync)
|
|
.AddEndpointFilter<StrictAntiforgeryEndpointFilter>();
|
|
group.MapGet("/{jobId:guid}/events", EventsAsync);
|
|
return app;
|
|
}
|
|
|
|
private static async Task<IResult> QueueCourseTaggingAsync(
|
|
[FromBody] QueueCourseTaggingJobRequest request,
|
|
QueueBackgroundJobUseCase useCase,
|
|
IWorkspaceRepository workspaces,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(context.Request.Headers["Idempotency-Key"]))
|
|
return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Idempotency-Key is verplicht.");
|
|
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
|
if (workspaceId is null)
|
|
return Results.Problem(statusCode: 404, type: ProblemTypes.NotFound);
|
|
|
|
EnqueueJobResult result;
|
|
try
|
|
{
|
|
result = await useCase.QueueCourseTaggingAsync(
|
|
new QueueCourseTaggingJobCommand(
|
|
workspaceId.Value, CurrentUserId(context.User), request.RootPageId, request.Tag,
|
|
context.Request.Headers["Idempotency-Key"].ToString(), CorrelationId(context)),
|
|
cancellationToken);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "De cursustagtaak is ongeldig.");
|
|
}
|
|
return ToEnqueueResult(result);
|
|
}
|
|
|
|
private static async Task<IResult> QueueBackupAsync(
|
|
QueueBackupJobRequest _,
|
|
QueueBackgroundJobUseCase useCase,
|
|
IWorkspaceRepository workspaces,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(context.Request.Headers["Idempotency-Key"]))
|
|
return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Idempotency-Key is verplicht.");
|
|
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
|
if (workspaceId is null)
|
|
return Results.Problem(statusCode: 404, type: ProblemTypes.NotFound);
|
|
EnqueueJobResult result;
|
|
try
|
|
{
|
|
result = await useCase.QueueBackupAsync(
|
|
new QueueJobCommand(workspaceId.Value, CurrentUserId(context.User),
|
|
context.Request.Headers["Idempotency-Key"].ToString(), CorrelationId(context)), cancellationToken);
|
|
}
|
|
catch (ArgumentException)
|
|
{
|
|
return Results.Problem(statusCode: 400, type: ProblemTypes.Validation, title: "Een geldige Idempotency-Key is verplicht.");
|
|
}
|
|
return ToEnqueueResult(result);
|
|
}
|
|
|
|
private static async Task<Results<Ok<BackgroundJobListResponse>, ProblemHttpResult>> ListAsync(
|
|
BackgroundJobQueries queries,
|
|
IWorkspaceRepository workspaces,
|
|
[FromQuery] int? limit,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
|
if (workspaceId is null)
|
|
return TypedResults.Problem(statusCode: 404, type: ProblemTypes.NotFound);
|
|
var items = await queries.ListAsync(workspaceId.Value, limit ?? 50, cancellationToken);
|
|
return TypedResults.Ok(new BackgroundJobListResponse(items.Select(ToResponse).ToList()));
|
|
}
|
|
|
|
private static async Task<Results<Ok<BackgroundJobResponse>, NotFound>> GetAsync(
|
|
Guid jobId,
|
|
BackgroundJobQueries queries,
|
|
IWorkspaceRepository workspaces,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
|
if (workspaceId is null)
|
|
return TypedResults.NotFound();
|
|
var job = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken);
|
|
return job is null ? TypedResults.NotFound() : TypedResults.Ok(ToResponse(job));
|
|
}
|
|
|
|
private static async Task<IResult> CancelAsync(
|
|
Guid jobId,
|
|
CancelBackgroundJobUseCase useCase,
|
|
IWorkspaceRepository workspaces,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
|
if (workspaceId is null)
|
|
return Results.NotFound();
|
|
var (status, job) = await useCase.ExecuteAsync(
|
|
workspaceId.Value, jobId, CurrentUserId(context.User), CorrelationId(context), cancellationToken);
|
|
if (status == CancelBackgroundJobStatus.NotFound || job is null)
|
|
return Results.NotFound();
|
|
return Results.Ok(ToResponse(job));
|
|
}
|
|
|
|
private static async Task<IResult> EventsAsync(
|
|
Guid jobId,
|
|
BackgroundJobQueries queries,
|
|
IWorkspaceRepository workspaces,
|
|
IUserAccountManager users,
|
|
IWorkspaceMembershipRepository memberships,
|
|
BackgroundJobsOptions options,
|
|
TimeProvider timeProvider,
|
|
HttpContext context,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var workspaceId = await workspaces.GetDefaultWorkspaceIdAsync(cancellationToken);
|
|
if (workspaceId is null)
|
|
return Results.NotFound();
|
|
var initial = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken);
|
|
if (initial is null)
|
|
return Results.NotFound();
|
|
|
|
context.Response.StatusCode = StatusCodes.Status200OK;
|
|
context.Response.ContentType = "text/event-stream; charset=utf-8";
|
|
context.Response.Headers["Cache-Control"] = "no-cache, no-transform";
|
|
context.Response.Headers.Append("X-Accel-Buffering", "no");
|
|
var actorId = CurrentUserId(context.User);
|
|
var lastVersion = -1L;
|
|
var lastHeartbeat = timeProvider.GetUtcNow();
|
|
|
|
while (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
var actor = await users.FindByIdAsync(actorId, cancellationToken);
|
|
var role = actor is { Enabled: true }
|
|
? await memberships.GetRoleAsync(workspaceId.Value, actorId, cancellationToken)
|
|
: null;
|
|
if (role != WorkspaceRole.Administrator)
|
|
break;
|
|
|
|
var job = await queries.GetAsync(workspaceId.Value, jobId, cancellationToken);
|
|
if (job is null)
|
|
break;
|
|
if (job.IsTerminal)
|
|
{
|
|
// Terminal state must close every stream, even if a concurrent
|
|
// worker update left the terminal snapshot at the last event ID.
|
|
await WriteEventAsync(context, "terminal", job, cancellationToken);
|
|
break;
|
|
}
|
|
if (job.Version != lastVersion)
|
|
{
|
|
var eventName = lastVersion < 0 ? "job" : "progress";
|
|
await WriteEventAsync(context, eventName, job, cancellationToken);
|
|
lastVersion = job.Version;
|
|
lastHeartbeat = timeProvider.GetUtcNow();
|
|
}
|
|
else if (timeProvider.GetUtcNow() - lastHeartbeat >= TimeSpan.FromSeconds(options.SseHeartbeatSeconds))
|
|
{
|
|
await context.Response.WriteAsync(": heartbeat\n\n", cancellationToken);
|
|
await context.Response.Body.FlushAsync(cancellationToken);
|
|
lastHeartbeat = timeProvider.GetUtcNow();
|
|
}
|
|
|
|
await Task.Delay(TimeSpan.FromMilliseconds(options.SsePollMilliseconds), timeProvider, cancellationToken);
|
|
}
|
|
return Results.Empty;
|
|
}
|
|
|
|
private static async Task WriteEventAsync(HttpContext context, string eventName, BackgroundJob job, CancellationToken cancellationToken)
|
|
{
|
|
var data = JsonSerializer.Serialize(ToResponse(job), JsonOptions);
|
|
await context.Response.WriteAsync($"id: {job.Version}\nevent: {eventName}\ndata: {data}\n\n", cancellationToken);
|
|
await context.Response.Body.FlushAsync(cancellationToken);
|
|
}
|
|
|
|
private static IResult ToEnqueueResult(EnqueueJobResult result) => result.Status switch
|
|
{
|
|
EnqueueJobStatus.Created or EnqueueJobStatus.Existing when result.Job is not null =>
|
|
Results.Accepted($"/api/v1/jobs/{result.Job.Id:D}", ToResponse(result.Job)),
|
|
EnqueueJobStatus.IdempotencyConflict => Results.Problem(
|
|
statusCode: 409, type: ProblemTypes.IdempotencyKeyConflict,
|
|
title: "Idempotency-Key is al gebruikt voor een andere aanvraag."),
|
|
EnqueueJobStatus.RootNotFound => Results.Problem(statusCode: 404, type: ProblemTypes.PageNotFound, title: "Hoofdpagina niet gevonden."),
|
|
EnqueueJobStatus.TooManyPages => Results.Problem(
|
|
statusCode: 413, type: ProblemTypes.Validation,
|
|
title: "De cursuspaginaboom overschrijdt de ingestelde taaklimiet."),
|
|
EnqueueJobStatus.QueueFull => Results.Problem(
|
|
statusCode: 429, title: "De taaklimiet is bereikt. Wacht tot lopende taken klaar zijn of het dagbudget is hersteld."),
|
|
_ => Results.Problem(statusCode: 500, title: "De taak kon niet worden ingepland.")
|
|
};
|
|
|
|
private static BackgroundJobResponse ToResponse(BackgroundJob job)
|
|
{
|
|
var errors = Array.Empty<BackgroundJobErrorResponse>();
|
|
try
|
|
{
|
|
errors = (JsonSerializer.Deserialize<BackgroundJobError[]>(job.ErrorsJson) ?? [])
|
|
.Select(error => new BackgroundJobErrorResponse(error.Code, error.Message, error.ItemId))
|
|
.ToArray();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// A damaged error summary must not turn the read API into a 500 or expose raw storage.
|
|
}
|
|
JsonElement? result = null;
|
|
if (!string.IsNullOrWhiteSpace(job.ResultJson))
|
|
{
|
|
try { result = JsonDocument.Parse(job.ResultJson).RootElement.Clone(); }
|
|
catch (JsonException) { result = null; }
|
|
}
|
|
|
|
return new BackgroundJobResponse(
|
|
job.Id, job.WorkspaceId, job.ActorId,
|
|
job.Type switch { BackgroundJobType.CourseTagging => "course-tagging", BackgroundJobType.Backup => "backup", _ => "unknown" },
|
|
ToStatus(job.Status), job.CreatedAt, job.StartedAt, job.CompletedAt,
|
|
job.CancelRequestedAt, job.CancelRequestedAt is not null,
|
|
job.Progress, job.Total, job.Processed, job.Succeeded, job.Skipped, job.Failed,
|
|
errors, result, job.CorrelationId, job.Version);
|
|
}
|
|
|
|
private static string ToStatus(BackgroundJobStatus status) => status switch
|
|
{
|
|
BackgroundJobStatus.Queued => "queued",
|
|
BackgroundJobStatus.Running => "running",
|
|
BackgroundJobStatus.Succeeded => "succeeded",
|
|
BackgroundJobStatus.PartiallySucceeded => "partially_succeeded",
|
|
BackgroundJobStatus.Failed => "failed",
|
|
BackgroundJobStatus.Cancelled => "cancelled",
|
|
_ => "failed"
|
|
};
|
|
|
|
private static Guid CurrentUserId(ClaimsPrincipal principal) =>
|
|
Guid.Parse(principal.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
|
|
|
private static string? CorrelationId(HttpContext context) =>
|
|
context.Items[CorrelationIdMiddleware.ItemKey] as string;
|
|
}
|