fix: change upload name
This commit is contained in:
@@ -56,11 +56,10 @@ public class CreateDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentCon
|
|||||||
|
|
||||||
if (request.Type == DocumentType.Document && !string.IsNullOrEmpty(request.Content))
|
if (request.Type == DocumentType.Document && !string.IsNullOrEmpty(request.Content))
|
||||||
{
|
{
|
||||||
await contentStore.EnsureBucketAsync(doc.ProjectId, ct);
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(request.Content);
|
var bytes = Encoding.UTF8.GetBytes(request.Content);
|
||||||
using (var ms = new MemoryStream(bytes))
|
using (var ms = new MemoryStream(bytes))
|
||||||
{
|
{
|
||||||
await contentStore.UploadAsync(doc.ProjectId, doc.Id, "text/html; charset=utf-8", ms, ct);
|
doc.StorageKey = await contentStore.UploadAsync(doc.ProjectId, doc.Title, "text/html; charset=utf-8", ms, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.ContentSize = bytes.Length;
|
doc.ContentSize = bytes.Length;
|
||||||
|
|||||||
@@ -14,29 +14,35 @@ public class DeleteDocumentHandler(IUnitOfWork uow, IDocumentContentStore conten
|
|||||||
var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct);
|
var doc = await DocumentAccess.GetDocumentForUserAsync(uow, command.UserId, command.DocumentId, ct);
|
||||||
await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Delete, ct);
|
await DocumentAccess.EnsureDocumentPermissionAsync(uow, command.UserId, doc.ProjectId, PermissionAction.Delete, ct);
|
||||||
|
|
||||||
var allIds = new List<(Guid ProjectId, Guid DocumentId)>();
|
var keys = new List<string>();
|
||||||
await CollectDescendants(uow, command.DocumentId, doc.ProjectId, allIds, ct);
|
await CollectDescendants(uow, command.DocumentId, keys, ct);
|
||||||
allIds.Add((doc.ProjectId, doc.Id));
|
if (!string.IsNullOrEmpty(doc.StorageKey))
|
||||||
|
{
|
||||||
|
keys.Add(doc.StorageKey);
|
||||||
|
}
|
||||||
|
|
||||||
await DocumentAccess.DeleteDescendantsAsync(uow, command.DocumentId, ct);
|
await DocumentAccess.DeleteDescendantsAsync(uow, command.DocumentId, ct);
|
||||||
uow.Documents.Remove(doc);
|
uow.Documents.Remove(doc);
|
||||||
await uow.SaveChangesAsync(ct);
|
await uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
foreach (var (projectId, documentId) in allIds)
|
foreach (var key in keys)
|
||||||
{
|
{
|
||||||
await contentStore.DeleteAsync(projectId, documentId, ct);
|
await contentStore.DeleteAsync(key, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task CollectDescendants(
|
private static async Task CollectDescendants(
|
||||||
IUnitOfWork uow, Guid parentId, Guid projectId,
|
IUnitOfWork uow, Guid parentId,
|
||||||
List<(Guid ProjectId, Guid DocumentId)> ids, CancellationToken ct)
|
List<string> keys, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var children = await uow.Documents.GetChildrenAsync(parentId, ct);
|
var children = await uow.Documents.GetChildrenAsync(parentId, ct);
|
||||||
foreach (var child in children)
|
foreach (var child in children)
|
||||||
{
|
{
|
||||||
ids.Add((projectId, child.Id));
|
if (!string.IsNullOrEmpty(child.StorageKey))
|
||||||
await CollectDescendants(uow, child.Id, projectId, ids, ct);
|
{
|
||||||
|
keys.Add(child.StorageKey);
|
||||||
|
}
|
||||||
|
await CollectDescendants(uow, child.Id, keys, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,16 @@ public class UpdateDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentCon
|
|||||||
|
|
||||||
if (doc.Type == DocumentType.Document)
|
if (doc.Type == DocumentType.Document)
|
||||||
{
|
{
|
||||||
await contentStore.EnsureBucketAsync(doc.ProjectId, ct);
|
var previousKey = doc.StorageKey;
|
||||||
var bytes = Encoding.UTF8.GetBytes(request.Content ?? "");
|
var bytes = Encoding.UTF8.GetBytes(request.Content ?? "");
|
||||||
using (var ms = new MemoryStream(bytes))
|
using (var ms = new MemoryStream(bytes))
|
||||||
{
|
{
|
||||||
await contentStore.UploadAsync(doc.ProjectId, doc.Id, "text/html; charset=utf-8", ms, ct);
|
doc.StorageKey = await contentStore.UploadAsync(doc.ProjectId, doc.Title, "text/html; charset=utf-8", ms, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(previousKey) && previousKey != doc.StorageKey)
|
||||||
|
{
|
||||||
|
await contentStore.DeleteAsync(previousKey, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
doc.ContentSize = bytes.Length;
|
doc.ContentSize = bytes.Length;
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ namespace mws.backend.dotnet.application.Documents;
|
|||||||
|
|
||||||
public interface IDocumentContentStore
|
public interface IDocumentContentStore
|
||||||
{
|
{
|
||||||
Task EnsureBucketAsync(Guid projectId, CancellationToken ct);
|
Task<string> UploadAsync(Guid projectId, string fileName, string contentType, Stream body, CancellationToken ct);
|
||||||
Task UploadAsync(Guid projectId, Guid documentId, string contentType, Stream body, CancellationToken ct);
|
Task<Stream> DownloadAsync(string storageKey, CancellationToken ct);
|
||||||
Task<Stream> DownloadAsync(Guid projectId, Guid documentId, CancellationToken ct);
|
Task DeleteAsync(string storageKey, CancellationToken ct);
|
||||||
Task<bool> ExistsAsync(Guid projectId, Guid documentId, CancellationToken ct);
|
|
||||||
Task DeleteAsync(Guid projectId, Guid documentId, CancellationToken ct);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ public class GetDocumentHandler(IUnitOfWork uow, IMapper mapper, IDocumentConten
|
|||||||
var doc = await DocumentAccess.GetDocumentForUserAsync(uow, query.UserId, query.DocumentId, ct);
|
var doc = await DocumentAccess.GetDocumentForUserAsync(uow, query.UserId, query.DocumentId, ct);
|
||||||
var dto = mapper.Map<DocumentDto>(doc);
|
var dto = mapper.Map<DocumentDto>(doc);
|
||||||
|
|
||||||
if (doc.Type == DocumentType.Document)
|
if (doc.Type == DocumentType.Document && !string.IsNullOrEmpty(doc.StorageKey))
|
||||||
{
|
{
|
||||||
await using var stream = await contentStore.DownloadAsync(doc.ProjectId, doc.Id, ct);
|
await using var stream = await contentStore.DownloadAsync(doc.StorageKey, ct);
|
||||||
if (stream != Stream.Null)
|
if (stream != Stream.Null)
|
||||||
{
|
{
|
||||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public class Document
|
|||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public DocumentType Type { get; set; } = DocumentType.Document;
|
public DocumentType Type { get; set; } = DocumentType.Document;
|
||||||
public string? ContentHash { get; set; }
|
public string? ContentHash { get; set; }
|
||||||
|
public string? StorageKey { get; set; }
|
||||||
public long? ContentSize { get; set; }
|
public long? ContentSize { get; set; }
|
||||||
public DateTime? UpdatedContentAt { get; set; }
|
public DateTime? UpdatedContentAt { get; set; }
|
||||||
public Guid CreatedBy { get; set; }
|
public Guid CreatedBy { get; set; }
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ namespace mws.backend.dotnet.infrastructure.Migrations
|
|||||||
b.Property<Guid>("ProjectId")
|
b.Property<Guid>("ProjectId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("StorageKey")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(300)
|
.HasMaxLength(300)
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ namespace mws.backend.dotnet.infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ProjectId = table.Column<Guid>(type: "uuid", nullable: false),
|
ProjectId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
StorageKey = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||||
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
Title = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
||||||
Description = table.Column<string>(type: "text", nullable: true),
|
Description = table.Column<string>(type: "text", nullable: true),
|
||||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ namespace mws.backend.dotnet.infrastructure.Migrations
|
|||||||
b.Property<Guid>("ProjectId")
|
b.Property<Guid>("ProjectId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("StorageKey")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(300)
|
.HasMaxLength(300)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class DocumentConfiguration : IEntityTypeConfiguration<Document>
|
|||||||
e.Property(d => d.Title).HasMaxLength(300).IsRequired();
|
e.Property(d => d.Title).HasMaxLength(300).IsRequired();
|
||||||
e.Property(d => d.Type).HasConversion<string>().HasMaxLength(20);
|
e.Property(d => d.Type).HasConversion<string>().HasMaxLength(20);
|
||||||
e.Property(d => d.ContentHash).HasMaxLength(64);
|
e.Property(d => d.ContentHash).HasMaxLength(64);
|
||||||
|
e.Property(d => d.StorageKey).HasMaxLength(512);
|
||||||
e.Property(d => d.ContentSize);
|
e.Property(d => d.ContentSize);
|
||||||
e.Property(d => d.UpdatedContentAt);
|
e.Property(d => d.UpdatedContentAt);
|
||||||
|
|
||||||
|
|||||||
@@ -15,35 +15,45 @@ public class S3DocumentContentStore(
|
|||||||
{
|
{
|
||||||
private string Bucket => opt.Value.Bucket;
|
private string Bucket => opt.Value.Bucket;
|
||||||
|
|
||||||
private static string Key(Guid projectId, Guid documentId) => $"{projectId:N}/{documentId:N}.html";
|
public static string FileNameNow(string name)
|
||||||
|
|
||||||
public Task EnsureBucketAsync(Guid projectId, CancellationToken ct) => Task.CompletedTask;
|
|
||||||
|
|
||||||
public async Task UploadAsync(Guid projectId, Guid documentId, string contentType, Stream body, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
|
var slug = new string(name.Trim().ToLowerInvariant()
|
||||||
|
.Select(c => char.IsLetterOrDigit(c) ? c : '-')
|
||||||
|
.ToArray()).Trim('-');
|
||||||
|
if (slug.Length == 0)
|
||||||
|
{
|
||||||
|
slug = "document";
|
||||||
|
}
|
||||||
|
return $"{slug}_{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> UploadAsync(Guid projectId, string fileName, string contentType, Stream body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var key = $"{projectId:N}/{FileNameNow(fileName)}.html";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await s3.PutObjectAsync(new PutObjectRequest
|
await s3.PutObjectAsync(new PutObjectRequest
|
||||||
{
|
{
|
||||||
BucketName = Bucket,
|
BucketName = Bucket,
|
||||||
Key = Key(projectId, documentId),
|
Key = key,
|
||||||
InputStream = body,
|
InputStream = body,
|
||||||
ContentType = contentType,
|
ContentType = contentType,
|
||||||
AutoCloseStream = false,
|
AutoCloseStream = false,
|
||||||
}, ct);
|
}, ct);
|
||||||
|
return key;
|
||||||
}
|
}
|
||||||
catch (AmazonS3Exception ex)
|
catch (AmazonS3Exception ex)
|
||||||
{
|
{
|
||||||
log.LogError(ex, "MinIO upload failed for document {DocumentId}", documentId);
|
log.LogError(ex, "MinIO upload failed for {Key}", key);
|
||||||
throw new StorageUnavailableException("Document upload failed", ex);
|
throw new StorageUnavailableException("Document upload failed", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Stream> DownloadAsync(Guid projectId, Guid documentId, CancellationToken ct)
|
public async Task<Stream> DownloadAsync(string storageKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await s3.GetObjectAsync(Bucket, Key(projectId, documentId), ct);
|
var response = await s3.GetObjectAsync(Bucket, storageKey, ct);
|
||||||
return response.ResponseStream;
|
return response.ResponseStream;
|
||||||
}
|
}
|
||||||
catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||||
@@ -52,33 +62,20 @@ public class S3DocumentContentStore(
|
|||||||
}
|
}
|
||||||
catch (AmazonS3Exception ex)
|
catch (AmazonS3Exception ex)
|
||||||
{
|
{
|
||||||
log.LogError(ex, "MinIO download failed for document {DocumentId}", documentId);
|
log.LogError(ex, "MinIO download failed for {Key}", storageKey);
|
||||||
throw new StorageUnavailableException("Document download failed", ex);
|
throw new StorageUnavailableException("Document download failed", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> ExistsAsync(Guid projectId, Guid documentId, CancellationToken ct)
|
public async Task DeleteAsync(string storageKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await s3.GetObjectMetadataAsync(Bucket, Key(projectId, documentId), ct);
|
await s3.DeleteObjectAsync(Bucket, storageKey, ct);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task DeleteAsync(Guid projectId, Guid documentId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await s3.DeleteObjectAsync(Bucket, Key(projectId, documentId), ct);
|
|
||||||
}
|
}
|
||||||
catch (AmazonS3Exception ex)
|
catch (AmazonS3Exception ex)
|
||||||
{
|
{
|
||||||
log.LogWarning(ex, "MinIO delete failed for document {DocumentId}", documentId);
|
log.LogWarning(ex, "MinIO delete failed for {Key}", storageKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user