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); } } } }