246 lines
11 KiB
C#
246 lines
11 KiB
C#
using ItWorx.KnowledgeHub.Application.Audit;
|
|
using ItWorx.KnowledgeHub.Application.Ingestion;
|
|
using ItWorx.KnowledgeHub.Application.Knowledge;
|
|
using ItWorx.KnowledgeHub.Application.Projects;
|
|
using ItWorx.KnowledgeHub.Domain.Attachments;
|
|
|
|
namespace ItWorx.KnowledgeHub.Application.Attachments.UseCases;
|
|
|
|
public enum UploadAttachmentStatus
|
|
{
|
|
Success,
|
|
PageNotFound,
|
|
StagingSessionNotFound,
|
|
IngestionProposalNotFound,
|
|
ProjectNotFound,
|
|
TooLarge,
|
|
EmptyFile,
|
|
InvalidFilename,
|
|
Rejected
|
|
}
|
|
|
|
public enum AttachmentOwnerKind
|
|
{
|
|
Page,
|
|
StagingSession,
|
|
IngestionProposal,
|
|
Project
|
|
}
|
|
|
|
/// <summary>An attachment upload targets exactly one owner: either an existing,
|
|
/// already-published page, an open page-creation staging session, an ingestion
|
|
/// proposal awaiting review, or a project icon
|
|
/// (docs/adr/ADR-025-v2-r1-data-integrity-remediation.md). Both paths share the same
|
|
/// quarantine/hash/signature/dedup/commit pipeline below — only which
|
|
/// <see cref="AttachmentUsage"/> gets created differs.</summary>
|
|
public sealed record AttachmentOwner(AttachmentOwnerKind Kind, Guid Id)
|
|
{
|
|
public static AttachmentOwner ForPage(Guid pageId) => new(AttachmentOwnerKind.Page, pageId);
|
|
|
|
public static AttachmentOwner ForStagingSession(Guid stagingSessionId) => new(AttachmentOwnerKind.StagingSession, stagingSessionId);
|
|
|
|
public static AttachmentOwner ForIngestionProposal(Guid proposalId) => new(AttachmentOwnerKind.IngestionProposal, proposalId);
|
|
|
|
public static AttachmentOwner ForProject(Guid projectId) => new(AttachmentOwnerKind.Project, projectId);
|
|
}
|
|
|
|
public sealed record UploadAttachmentCommand(
|
|
Guid WorkspaceId,
|
|
AttachmentOwner Owner,
|
|
string DeclaredFilename,
|
|
string? DeclaredContentType,
|
|
Stream Content,
|
|
Guid ActorId,
|
|
string? CorrelationId,
|
|
bool DeferPersistence = false,
|
|
long? MaximumUploadBytes = null,
|
|
IReadOnlySet<string>? AllowedMediaTypes = null);
|
|
|
|
public sealed record UploadAttachmentResult(
|
|
UploadAttachmentStatus Status,
|
|
Guid? ReferenceId,
|
|
string? StoredFilename,
|
|
string? DetectedMediaType,
|
|
AttachmentDeliveryPolicy? DeliveryPolicy,
|
|
long? ByteLength,
|
|
string? BlobSha256,
|
|
bool NewBlobCommitted,
|
|
string? Detail)
|
|
{
|
|
public static UploadAttachmentResult Success(
|
|
Guid referenceId, string storedFilename, string detectedMediaType, AttachmentDeliveryPolicy deliveryPolicy, long byteLength,
|
|
string blobSha256, bool newBlobCommitted) =>
|
|
new(UploadAttachmentStatus.Success, referenceId, storedFilename, detectedMediaType, deliveryPolicy, byteLength,
|
|
blobSha256, newBlobCommitted, null);
|
|
|
|
public static UploadAttachmentResult Failure(UploadAttachmentStatus status, string? detail = null) =>
|
|
new(status, null, null, null, null, null, null, false, detail);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The upload pipeline from docs/13-attachments.md: stream once to quarantine while
|
|
/// hashing, verify size, detect the real signature, apply delivery policy, then
|
|
/// atomically commit the blob before creating the logical reference and page usage in
|
|
/// one transaction. Every failure path discards the quarantined file
|
|
/// (docs/13-attachments.md — "remove quarantine data on every failure/cancellation").
|
|
/// </summary>
|
|
public sealed class UploadAttachmentUseCase(
|
|
IPageRepository pages,
|
|
IPageCreationSessionRepository stagingSessions,
|
|
IIngestionProposalRepository ingestionProposals,
|
|
IProjectRepository projects,
|
|
IAttachmentRepository attachments,
|
|
IAttachmentBlobStore blobStore,
|
|
IContentSignatureDetector signatureDetector,
|
|
IAuditEventRecorder auditEvents,
|
|
AttachmentContentLock contentLock,
|
|
TimeProvider timeProvider,
|
|
long maximumUploadBytes = 100 * 1024 * 1024)
|
|
{
|
|
public async Task<UploadAttachmentResult> ExecuteAsync(UploadAttachmentCommand command, CancellationToken cancellationToken)
|
|
{
|
|
if (command.Owner.Kind == AttachmentOwnerKind.Page)
|
|
{
|
|
var page = await pages.FindAsync(command.Owner.Id, cancellationToken);
|
|
if (page is null)
|
|
{
|
|
return UploadAttachmentResult.Failure(UploadAttachmentStatus.PageNotFound);
|
|
}
|
|
}
|
|
else if (command.Owner.Kind == AttachmentOwnerKind.StagingSession)
|
|
{
|
|
var now = timeProvider.GetUtcNow();
|
|
var session = await stagingSessions.FindAsync(command.WorkspaceId, command.Owner.Id, cancellationToken);
|
|
if (session is null || !session.IsOpen || session.IsExpired(now))
|
|
{
|
|
return UploadAttachmentResult.Failure(UploadAttachmentStatus.StagingSessionNotFound);
|
|
}
|
|
}
|
|
else if (command.Owner.Kind == AttachmentOwnerKind.IngestionProposal)
|
|
{
|
|
var proposal = await ingestionProposals.FindAsync(command.WorkspaceId, command.Owner.Id, cancellationToken);
|
|
if (proposal is null)
|
|
{
|
|
return UploadAttachmentResult.Failure(UploadAttachmentStatus.IngestionProposalNotFound);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var project = await projects.FindAsync(command.Owner.Id, cancellationToken);
|
|
if (project is null || project.WorkspaceId != command.WorkspaceId)
|
|
{
|
|
return UploadAttachmentResult.Failure(UploadAttachmentStatus.ProjectNotFound);
|
|
}
|
|
}
|
|
|
|
SafeFilename filename;
|
|
try
|
|
{
|
|
filename = SafeFilename.Create(command.DeclaredFilename);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return UploadAttachmentResult.Failure(UploadAttachmentStatus.InvalidFilename, ex.Message);
|
|
}
|
|
|
|
var quarantined = await blobStore.WriteToQuarantineAsync(command.Content, cancellationToken);
|
|
var committed = false;
|
|
|
|
try
|
|
{
|
|
if (quarantined.ByteLength == 0)
|
|
{
|
|
return UploadAttachmentResult.Failure(UploadAttachmentStatus.EmptyFile);
|
|
}
|
|
|
|
var uploadLimit = Math.Min(maximumUploadBytes, command.MaximumUploadBytes ?? maximumUploadBytes);
|
|
if (quarantined.ByteLength > uploadLimit)
|
|
{
|
|
return UploadAttachmentResult.Failure(
|
|
UploadAttachmentStatus.TooLarge,
|
|
$"Bestand is groter dan de toegestane {uploadLimit / (1024 * 1024)} MiB.");
|
|
}
|
|
|
|
var prefix = await blobStore.ReadQuarantinePrefixAsync(quarantined.QuarantineToken, 4096, cancellationToken);
|
|
var signature = signatureDetector.Detect(prefix, command.DeclaredContentType, filename.LastExtension());
|
|
|
|
if (signature.DeliveryPolicy == AttachmentDeliveryPolicy.Rejected)
|
|
{
|
|
return UploadAttachmentResult.Failure(
|
|
UploadAttachmentStatus.Rejected,
|
|
$"Bestandstype '{signature.DetectedMediaType}' wordt niet toegestaan als bijlage.");
|
|
}
|
|
|
|
if (command.AllowedMediaTypes is { } allowedMediaTypes && !allowedMediaTypes.Contains(signature.DetectedMediaType))
|
|
{
|
|
return UploadAttachmentResult.Failure(
|
|
UploadAttachmentStatus.Rejected,
|
|
$"Bestandstype '{signature.DetectedMediaType}' wordt voor dit gebruik niet toegestaan.");
|
|
}
|
|
|
|
// Deduplication spans both the filesystem commit and the database blob
|
|
// row. Serialize that small critical section per content hash so two
|
|
// concurrent requests cannot both observe "missing", race the atomic
|
|
// rename and then race the attachment_blobs primary key. Unrelated hashes
|
|
// remain fully concurrent; idle lock entries are removed safely.
|
|
using var contentLease = await contentLock.AcquireAsync(quarantined.Sha256, cancellationToken);
|
|
var now = timeProvider.GetUtcNow();
|
|
var existingBlob = await attachments.FindBlobAsync(quarantined.Sha256, cancellationToken);
|
|
|
|
if (existingBlob is null)
|
|
{
|
|
await blobStore.CommitAsync(quarantined.QuarantineToken, quarantined.Sha256, cancellationToken);
|
|
committed = true;
|
|
attachments.AddBlob(AttachmentBlob.Create(
|
|
quarantined.Sha256, quarantined.ByteLength, signature.DetectedMediaType, signature.DeliveryPolicy, now));
|
|
}
|
|
else
|
|
{
|
|
// Deduplication (docs/13-attachments.md): reuse the existing blob,
|
|
// never store the same content twice — the quarantined duplicate is
|
|
// discarded in the `finally` block below like any other non-committed
|
|
// quarantine file.
|
|
}
|
|
|
|
var reference = AttachmentReference.Create(command.WorkspaceId, quarantined.Sha256, filename.Value, command.ActorId, now);
|
|
attachments.AddReference(reference);
|
|
attachments.AddUsage(command.Owner.Kind switch
|
|
{
|
|
AttachmentOwnerKind.Page => AttachmentUsage.ForPage(reference.Id, command.Owner.Id, now),
|
|
AttachmentOwnerKind.StagingSession => AttachmentUsage.ForStagingSession(reference.Id, command.Owner.Id, now),
|
|
AttachmentOwnerKind.IngestionProposal => AttachmentUsage.ForIngestionProposal(reference.Id, command.Owner.Id, now),
|
|
AttachmentOwnerKind.Project => AttachmentUsage.ForProject(reference.Id, command.Owner.Id, now),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(command))
|
|
});
|
|
|
|
if (!command.DeferPersistence)
|
|
{
|
|
await attachments.SaveChangesAsync(cancellationToken);
|
|
|
|
await auditEvents.RecordAsync(
|
|
new AuditEventRecord(
|
|
AuditActorKind.User,
|
|
command.ActorId,
|
|
Source: "web",
|
|
Action: "attachment.uploaded",
|
|
TargetType: "attachment_reference",
|
|
TargetId: reference.Id.ToString(),
|
|
Summary: $"Bijlage '{filename.Value}' geüpload ({quarantined.ByteLength} bytes).",
|
|
command.CorrelationId,
|
|
now),
|
|
cancellationToken);
|
|
}
|
|
|
|
return UploadAttachmentResult.Success(reference.Id, filename.Value, signature.DetectedMediaType, signature.DeliveryPolicy,
|
|
quarantined.ByteLength, quarantined.Sha256, committed);
|
|
}
|
|
finally
|
|
{
|
|
if (!committed)
|
|
{
|
|
await blobStore.DiscardQuarantineAsync(quarantined.QuarantineToken, cancellationToken);
|
|
}
|
|
}
|
|
}
|
|
}
|