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
}
/// 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
/// gets created differs.
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? 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);
}
///
/// 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").
///
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 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);
}
}
}
}