Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
+245
@@ -0,0 +1,245 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user