diff --git a/src/Squidex.Domain.Apps.Write/Assets/AssetCommandHandler.cs b/src/Squidex.Domain.Apps.Write/Assets/AssetCommandHandler.cs index dd40d57cf..c84e01264 100644 --- a/src/Squidex.Domain.Apps.Write/Assets/AssetCommandHandler.cs +++ b/src/Squidex.Domain.Apps.Write/Assets/AssetCommandHandler.cs @@ -38,38 +38,55 @@ namespace Squidex.Domain.Apps.Write.Assets protected async Task On(CreateAsset command, CommandContext context) { - await handler.CreateAsync(context, async c => + command.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(command.File.OpenRead()); + try { - command.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(command.File.OpenRead()); + var asset = await handler.CreateAsync(context, async a => + { + a.Create(command); - c.Create(command); + await assetStore.UploadTemporaryAsync(context.ContextId.ToString(), command.File.OpenRead()); - await assetStore.UploadAsync(c.Id.ToString(), c.FileVersion, null, command.File.OpenRead()); + context.Succeed(EntityCreatedResult.Create(a.Id, a.Version)); + }); - context.Succeed(EntityCreatedResult.Create(c.Id, c.Version)); - }); + await assetStore.CopyTemporaryAsync(context.ContextId.ToString(), asset.Id.ToString(), asset.FileVersion, null); + } + finally + { + await assetStore.DeleteTemporaryAsync(context.ContextId.ToString()); + } } protected async Task On(UpdateAsset command, CommandContext context) { - await handler.UpdateAsync(context, async c => + command.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(command.File.OpenRead()); + + try { - command.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(command.File.OpenRead()); + var asset = await handler.UpdateAsync(context, async a => + { + a.Update(command); - c.Update(command); + await assetStore.UploadTemporaryAsync(context.ContextId.ToString(), command.File.OpenRead()); + }); - await assetStore.UploadAsync(c.Id.ToString(), c.FileVersion, null, command.File.OpenRead()); - }); + await assetStore.CopyTemporaryAsync(context.ContextId.ToString(), asset.Id.ToString(), asset.FileVersion, null); + } + finally + { + await assetStore.DeleteTemporaryAsync(context.ContextId.ToString()); + } } protected Task On(RenameAsset command, CommandContext context) { - return handler.UpdateAsync(context, c => c.Rename(command)); + return handler.UpdateAsync(context, a => a.Rename(command)); } protected Task On(DeleteAsset command, CommandContext context) { - return handler.UpdateAsync(context, c => c.Delete(command)); + return handler.UpdateAsync(context, a => a.Delete(command)); } public Task HandleAsync(CommandContext context) diff --git a/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs b/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs index a4cb5ad75..6d070e6c2 100644 --- a/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs +++ b/src/Squidex.Infrastructure.GoogleCloud/Assets/GoogleCloudAssetStore.cs @@ -8,6 +8,7 @@ using System; using System.IO; +using System.Linq; using System.Net; using System.Threading.Tasks; using Google; @@ -41,6 +42,36 @@ namespace Squidex.Infrastructure.Assets } } + public Task UploadTemporaryAsync(string name, Stream stream) + { + return storageClient.UploadObjectAsync(bucketName, name, "application/octet-stream", stream); + } + + public async Task UploadAsync(string id, long version, string suffix, Stream stream) + { + var objectName = GetObjectName(id, version, suffix); + + await storageClient.UploadObjectAsync(bucketName, objectName, "application/octet-stream", stream); + } + + public async Task CopyTemporaryAsync(string name, string id, long version, string suffix) + { + var objectName = GetObjectName(id, version, suffix); + + try + { + await storageClient.CopyObjectAsync(bucketName, name, bucketName, objectName); + } + catch (GoogleApiException ex) + { + if (ex.HttpStatusCode == HttpStatusCode.NotFound) + { + throw new AssetNotFoundException($"Asset {name} not found.", ex); + } + throw; + } + } + public async Task DownloadAsync(string id, long version, string suffix, Stream stream) { var objectName = GetObjectName(id, version, suffix); @@ -59,11 +90,16 @@ namespace Squidex.Infrastructure.Assets } } - public async Task UploadAsync(string id, long version, string suffix, Stream stream) + public async Task DeleteTemporaryAsync(string name) { - var objectName = GetObjectName(id, version, suffix); - - await storageClient.UploadObjectAsync(bucketName, objectName, "application/octet-stream", stream); + try + { + await storageClient.DeleteObjectAsync(bucketName, name); + } + catch + { + // ignored + } } private string GetObjectName(string id, long version, string suffix) @@ -75,14 +111,14 @@ namespace Squidex.Infrastructure.Assets throw new InvalidOperationException("No connection established yet."); } - var name = $"{id}_{version}"; - - if (!string.IsNullOrWhiteSpace(suffix)) - { - name += "_" + suffix; - } + var name = GetFileName(id, version, suffix); return name; } + + private static string GetFileName(string id, long version, string suffix) + { + return string.Join("_", new[] { id, version.ToString(), suffix }.Where(x => !string.IsNullOrWhiteSpace(x))); + } } } diff --git a/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs b/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs index 543195d17..b8ab2b1dc 100644 --- a/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs +++ b/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs @@ -7,8 +7,10 @@ // ========================================================================== using System.IO; +using System.Linq; using System.Threading.Tasks; using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Tasks; namespace Squidex.Infrastructure.Assets { @@ -49,6 +51,26 @@ namespace Squidex.Infrastructure.Assets } } + public async Task UploadTemporaryAsync(string name, Stream stream) + { + var file = GetFile(name); + + using (var fileStream = file.OpenWrite()) + { + await stream.CopyToAsync(fileStream); + } + } + + public async Task UploadAsync(string id, long version, string suffix, Stream stream) + { + var file = GetFile(id, version, suffix); + + using (var fileStream = file.OpenWrite()) + { + await stream.CopyToAsync(fileStream); + } + } + public async Task DownloadAsync(string id, long version, string suffix, Stream stream) { var file = GetFile(id, version, suffix); @@ -66,28 +88,60 @@ namespace Squidex.Infrastructure.Assets } } - public async Task UploadAsync(string id, long version, string suffix, Stream stream) + public Task CopyTemporaryAsync(string name, string id, long version, string suffix) { - var file = GetFile(id, version, suffix); + try + { + var file = GetFile(name); - using (var fileStream = file.OpenWrite()) + file.CopyTo(GetPath(id, version, suffix)); + + return TaskHelper.Done; + } + catch (FileNotFoundException ex) { - await stream.CopyToAsync(fileStream); + throw new AssetNotFoundException($"Asset {name} not found.", ex); } } - private FileInfo GetFile(string id, long version, string suffix) + public Task DeleteTemporaryAsync(string name) { - Guard.NotNullOrEmpty(id, nameof(id)); + try + { + var file = GetFile(name); - var path = Path.Combine(directory.FullName, $"{id}_{version}"); + file.Delete(); - if (!string.IsNullOrWhiteSpace(suffix)) + return TaskHelper.Done; + } + catch (FileNotFoundException ex) { - path += "_" + suffix; + throw new AssetNotFoundException($"Asset {name} not found.", ex); } + } - return new FileInfo(path); + private FileInfo GetFile(string id, long version, string suffix) + { + Guard.NotNullOrEmpty(id, nameof(id)); + + return GetFile(GetPath(id, version, suffix)); + } + + private FileInfo GetFile(string name) + { + Guard.NotNullOrEmpty(name, nameof(name)); + + return new FileInfo(GetPath(name)); + } + + private string GetPath(string name) + { + return Path.Combine(directory.FullName, name); + } + + private string GetPath(string id, long version, string suffix) + { + return Path.Combine(directory.FullName, string.Join("_", new[] { id, version.ToString(), suffix }.Where(x => !string.IsNullOrWhiteSpace(x)))); } } } diff --git a/src/Squidex.Infrastructure/Assets/IAssetStore.cs b/src/Squidex.Infrastructure/Assets/IAssetStore.cs index 69b8d3e8d..5612d273d 100644 --- a/src/Squidex.Infrastructure/Assets/IAssetStore.cs +++ b/src/Squidex.Infrastructure/Assets/IAssetStore.cs @@ -13,8 +13,14 @@ namespace Squidex.Infrastructure.Assets { public interface IAssetStore { + Task CopyTemporaryAsync(string name, string id, long version, string suffix); + Task DownloadAsync(string id, long version, string suffix, Stream stream); + Task UploadTemporaryAsync(string name, Stream stream); + Task UploadAsync(string id, long version, string suffix, Stream stream); + + Task DeleteTemporaryAsync(string name); } } \ No newline at end of file diff --git a/src/Squidex.Infrastructure/CQRS/Commands/AggregateHandler.cs b/src/Squidex.Infrastructure/CQRS/Commands/AggregateHandler.cs index ecab8169c..c2c5c746c 100644 --- a/src/Squidex.Infrastructure/CQRS/Commands/AggregateHandler.cs +++ b/src/Squidex.Infrastructure/CQRS/Commands/AggregateHandler.cs @@ -38,7 +38,7 @@ namespace Squidex.Infrastructure.CQRS.Commands this.domainObjectRepository = domainObjectRepository; } - public async Task CreateAsync(CommandContext context, Func creator) where T : class, IAggregate + public async Task CreateAsync(CommandContext context, Func creator) where T : class, IAggregate { Guard.NotNull(creator, nameof(creator)); Guard.NotNull(context, nameof(context)); @@ -54,9 +54,11 @@ namespace Squidex.Infrastructure.CQRS.Commands { context.Succeed(new EntityCreatedResult(aggregate.Id, aggregate.Version)); } + + return aggregate; } - public async Task UpdateAsync(CommandContext context, Func updater) where T : class, IAggregate + public async Task UpdateAsync(CommandContext context, Func updater) where T : class, IAggregate { Guard.NotNull(updater, nameof(updater)); Guard.NotNull(context, nameof(context)); @@ -72,6 +74,8 @@ namespace Squidex.Infrastructure.CQRS.Commands { context.Succeed(new EntitySavedResult(aggregate.Version)); } + + return aggregate; } private static IAggregateCommand GetCommand(CommandContext context) diff --git a/src/Squidex.Infrastructure/CQRS/Commands/IAggregateHandler.cs b/src/Squidex.Infrastructure/CQRS/Commands/IAggregateHandler.cs index 5d7ca75bb..27a69d74b 100644 --- a/src/Squidex.Infrastructure/CQRS/Commands/IAggregateHandler.cs +++ b/src/Squidex.Infrastructure/CQRS/Commands/IAggregateHandler.cs @@ -13,8 +13,8 @@ namespace Squidex.Infrastructure.CQRS.Commands { public interface IAggregateHandler { - Task CreateAsync(CommandContext context, Func creator) where T : class, IAggregate; + Task CreateAsync(CommandContext context, Func creator) where T : class, IAggregate; - Task UpdateAsync(CommandContext context, Func updater) where T : class, IAggregate; + Task UpdateAsync(CommandContext context, Func updater) where T : class, IAggregate; } } diff --git a/tests/Squidex.Domain.Apps.Write.Tests/Assets/AssetCommandHandlerTests.cs b/tests/Squidex.Domain.Apps.Write.Tests/Assets/AssetCommandHandlerTests.cs index 4b3e25dbb..f3c15f709 100644 --- a/tests/Squidex.Domain.Apps.Write.Tests/Assets/AssetCommandHandlerTests.cs +++ b/tests/Squidex.Domain.Apps.Write.Tests/Assets/AssetCommandHandlerTests.cs @@ -17,6 +17,7 @@ using Squidex.Infrastructure.CQRS.Commands; using Squidex.Infrastructure.Tasks; using Xunit; +// ReSharper disable ImplicitlyCapturedClosure // ReSharper disable ConvertToConstant.Local namespace Squidex.Domain.Apps.Write.Assets @@ -44,11 +45,11 @@ namespace Squidex.Domain.Apps.Write.Assets [Fact] public async Task Create_should_create_asset() { - SetupStore(0); - SetupImageInfo(); - var context = CreateContextForCommand(new CreateAsset { AssetId = assetId, File = file }); + SetupStore(0, context.ContextId); + SetupImageInfo(); + await TestCreate(asset, async _ => { await sut.HandleAsync(context); @@ -63,13 +64,13 @@ namespace Squidex.Domain.Apps.Write.Assets [Fact] public async Task Update_should_update_domain_object() { - SetupStore(1); + var context = CreateContextForCommand(new UpdateAsset { AssetId = assetId, File = file }); + + SetupStore(1, context.ContextId); SetupImageInfo(); CreateAsset(); - var context = CreateContextForCommand(new UpdateAsset { AssetId = assetId, File = file }); - await TestUpdate(asset, async _ => { await sut.HandleAsync(context); @@ -117,10 +118,18 @@ namespace Squidex.Domain.Apps.Write.Assets .Verifiable(); } - private void SetupStore(long version) + private void SetupStore(long version, Guid commitId) { assetStore - .Setup(x => x.UploadAsync(assetId.ToString(), version, null, stream)).Returns(TaskHelper.Done) + .Setup(x => x.UploadTemporaryAsync(commitId.ToString(), stream)).Returns(TaskHelper.Done) + .Verifiable(); + + assetStore + .Setup(x => x.CopyTemporaryAsync(commitId.ToString(), assetId.ToString(), version, null)).Returns(TaskHelper.Done) + .Verifiable(); + + assetStore + .Setup(x => x.DeleteTemporaryAsync(commitId.ToString())).Returns(TaskHelper.Done) .Verifiable(); } } diff --git a/tests/Squidex.Domain.Apps.Write.Tests/TestHelpers/HandlerTestBase.cs b/tests/Squidex.Domain.Apps.Write.Tests/TestHelpers/HandlerTestBase.cs index cf70b8191..2387ceb06 100644 --- a/tests/Squidex.Domain.Apps.Write.Tests/TestHelpers/HandlerTestBase.cs +++ b/tests/Squidex.Domain.Apps.Write.Tests/TestHelpers/HandlerTestBase.cs @@ -34,18 +34,26 @@ namespace Squidex.Domain.Apps.Write.TestHelpers IsUpdated = false; } - public Task CreateAsync(CommandContext context, Func creator) where V : class, IAggregate + public async Task CreateAsync(CommandContext context, Func creator) where V : class, IAggregate { IsCreated = true; - return creator(domainObject as V); + var @do = domainObject as V; + + await creator(domainObject as V); + + return @do; } - public Task UpdateAsync(CommandContext context, Func updater) where V : class, IAggregate + public async Task UpdateAsync(CommandContext context, Func updater) where V : class, IAggregate { IsUpdated = true; - return updater(domainObject as V); + var @do = domainObject as V; + + await updater(domainObject as V); + + return @do; } } diff --git a/tests/Squidex.Infrastructure.Tests/Assets/FolderAssetStoreTests.cs b/tests/Squidex.Infrastructure.Tests/Assets/FolderAssetStoreTests.cs index f789dce60..ae67c24d6 100644 --- a/tests/Squidex.Infrastructure.Tests/Assets/FolderAssetStoreTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Assets/FolderAssetStoreTests.cs @@ -48,11 +48,19 @@ namespace Squidex.Infrastructure.Assets } [Fact] - public Task Should_throw_exception_if_asset_not_found() + public Task Should_throw_exception_if_asset_to_download_is_not_found() { sut.Connect(); - return Assert.ThrowsAsync(() => sut.DownloadAsync(Guid.NewGuid().ToString(), 1, "suffix", new MemoryStream())); + return Assert.ThrowsAsync(() => sut.DownloadAsync(Id(), 1, "suffix", new MemoryStream())); + } + + [Fact] + public Task Should_throw_exception_if_asset_to_copy_is_not_found() + { + sut.Connect(); + + return Assert.ThrowsAsync(() => sut.CopyTemporaryAsync(Id(), Id(), 1, null)); } [Fact] @@ -60,7 +68,7 @@ namespace Squidex.Infrastructure.Assets { sut.Connect(); - var assetId = Guid.NewGuid().ToString(); + var assetId = Id(); var assetData = new MemoryStream(new byte[] { 0x1, 0x2, 0x3, 0x4 }); await sut.UploadAsync(assetId, 1, "suffix", assetData); @@ -72,6 +80,45 @@ namespace Squidex.Infrastructure.Assets Assert.Equal(assetData.ToArray(), readData.ToArray()); } + [Fact] + public async Task Should_commit_temporary_file() + { + sut.Connect(); + + var tempId = Id(); + + var assetId = Id(); + var assetData = new MemoryStream(new byte[] { 0x1, 0x2, 0x3, 0x4 }); + + await sut.UploadTemporaryAsync(tempId, assetData); + await sut.CopyTemporaryAsync(tempId, assetId, 1, "suffix"); + + var readData = new MemoryStream(); + + await sut.DownloadAsync(assetId, 1, "suffix", readData); + + Assert.Equal(assetData.ToArray(), readData.ToArray()); + } + + [Fact] + public async Task Should_ignore_when_deleting_twice() + { + sut.Connect(); + + var tempId = Id(); + + var assetData = new MemoryStream(new byte[] { 0x1, 0x2, 0x3, 0x4 }); + + await sut.UploadTemporaryAsync(tempId, assetData); + await sut.DeleteTemporaryAsync(tempId); + await sut.DeleteTemporaryAsync(tempId); + } + + private static string Id() + { + return Guid.NewGuid().ToString(); + } + private static string CreateInvalidPath() { var windir = Environment.GetEnvironmentVariable("windir");