mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Integrate SeaweedFS for S3-compatible file storage and photo upload (#254)
This commit is contained in:
@@ -537,22 +537,6 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rolls back <paramref name="transaction" />, swallowing any exception the rollback itself
|
||||
/// raises (for example when the provider has already completed the transaction after a
|
||||
/// connection failure) so the exception that triggered the rollback is what propagates.
|
||||
/// </summary>
|
||||
private static async Task RollbackQuietlyAsync(DbTransaction transaction)
|
||||
{
|
||||
try
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore: the original exception (rethrown by the caller) is what matters here.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes a joined row's <see cref="BreweryPost" />, <see cref="BreweryPostLocation" />,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using Domain.Entities;
|
||||
using Features.ImageUploads.Commands.UploadPhoto;
|
||||
using Features.ImageUploads.Repository;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.FileUpload;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
|
||||
namespace Features.PhotoUpload.Tests.Commands;
|
||||
|
||||
public class UploadPhotoHandlerTests
|
||||
{
|
||||
private readonly Mock<IFileStorageProvider> _fileStorageProviderMock = new();
|
||||
private readonly UploadPhotoHandler _handler;
|
||||
private readonly Mock<IPhotoUploadRepository> _repoMock = new();
|
||||
|
||||
public UploadPhotoHandlerTests()
|
||||
{
|
||||
_handler = new UploadPhotoHandler(_fileStorageProviderMock.Object, _repoMock.Object);
|
||||
}
|
||||
|
||||
private static Mock<IFormFile> ValidFile(
|
||||
string content = "fake-image-bytes",
|
||||
string contentType = "image/png"
|
||||
)
|
||||
{
|
||||
Mock<IFormFile> file = new();
|
||||
byte[] bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
|
||||
file.Setup(f => f.ContentType).Returns(contentType);
|
||||
file.Setup(f => f.Length).Returns(bytes.Length);
|
||||
return file;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UploadsFileUnderRequestedKey_AndPersistsPhoto_ReturnsNewPhotoId()
|
||||
{
|
||||
Mock<IFormFile> file = ValidFile();
|
||||
UploadPhotoCommand command = new(Guid.NewGuid(), "breweries/some-brewery-id", file.Object);
|
||||
|
||||
string? uploadedKey = null;
|
||||
string? uploadedContentType = null;
|
||||
_fileStorageProviderMock
|
||||
.Setup(p =>
|
||||
p.UploadAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.Callback<string, Stream, string, CancellationToken>(
|
||||
(key, _, contentType, _) =>
|
||||
{
|
||||
uploadedKey = key;
|
||||
uploadedContentType = contentType;
|
||||
}
|
||||
)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
Photo? persisted = null;
|
||||
_repoMock
|
||||
.Setup(r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<Photo, CancellationToken>((photo, _) => persisted = photo)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
Guid photoId = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
photoId.Should().NotBe(Guid.Empty);
|
||||
|
||||
uploadedKey.Should().Be($"breweries/some-brewery-id/{photoId}");
|
||||
uploadedContentType.Should().Be("image/png");
|
||||
|
||||
persisted.Should().NotBeNull();
|
||||
persisted!.Hyperlink.Should().Be(uploadedKey);
|
||||
persisted.UploadedById.Should().Be(command.UploadedById);
|
||||
|
||||
_fileStorageProviderMock.Verify(
|
||||
p =>
|
||||
p.UploadAsync(
|
||||
uploadedKey!,
|
||||
It.IsAny<Stream>(),
|
||||
"image/png",
|
||||
It.IsAny<CancellationToken>()
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
_repoMock.Verify(
|
||||
r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UploadOrderedBeforePersist_DoesNotPersistWhenUploadFails()
|
||||
{
|
||||
Mock<IFormFile> file = ValidFile();
|
||||
UploadPhotoCommand command = new(Guid.NewGuid(), "avatars", file.Object);
|
||||
|
||||
_fileStorageProviderMock
|
||||
.Setup(p =>
|
||||
p.UploadAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Stream>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()
|
||||
)
|
||||
)
|
||||
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
|
||||
|
||||
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
_repoMock.Verify(
|
||||
r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using Features.ImageUploads.Commands.UploadPhoto;
|
||||
using FluentAssertions;
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
|
||||
namespace Features.PhotoUpload.Tests.Commands;
|
||||
|
||||
public class UploadPhotoValidatorTests
|
||||
{
|
||||
private static readonly byte[] PngSignature =
|
||||
[
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A
|
||||
];
|
||||
|
||||
private static readonly byte[] JpegSignature =
|
||||
[
|
||||
0xFF, 0xD8, 0xFF
|
||||
];
|
||||
|
||||
private static readonly byte[] WebPSignature =
|
||||
[
|
||||
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50 // "RIFF____WEBP"
|
||||
];
|
||||
|
||||
private static readonly byte[] GifSignature =
|
||||
[
|
||||
0x47, 0x49, 0x46, 0x38, 0x39, 0x61 // "GIF89a"
|
||||
];
|
||||
|
||||
private static readonly byte[] BmpSignature =
|
||||
[
|
||||
0x42, 0x4D // "BM"
|
||||
];
|
||||
|
||||
private static readonly byte[] PdfSignature =
|
||||
[
|
||||
0x25, 0x50, 0x44, 0x46 // "%PDF"
|
||||
];
|
||||
|
||||
private static readonly byte[] PlainTextBytes = "not an image"u8.ToArray();
|
||||
|
||||
private const long MinSizeBytes = 1024; // 1 KiB
|
||||
private const long MaxSizeBytes = 24 * 1024 * 1024; // 24 MiB
|
||||
|
||||
private readonly UploadPhotoValidator _validator = new();
|
||||
|
||||
private static Mock<IFormFile> FileOfSize(long sizeInBytes)
|
||||
{
|
||||
byte[] bytes = new byte[Math.Max(sizeInBytes, PngSignature.Length)];
|
||||
PngSignature.CopyTo(bytes, 0);
|
||||
|
||||
Mock<IFormFile> file = new();
|
||||
file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
|
||||
file.Setup(f => f.ContentType).Returns("image/png");
|
||||
file.Setup(f => f.Length).Returns(sizeInBytes);
|
||||
return file;
|
||||
}
|
||||
|
||||
private static Mock<IFormFile> FileWithHeader(byte[] header, long sizeInBytes = 0)
|
||||
{
|
||||
byte[] bytes = new byte[Math.Max(sizeInBytes, Math.Max(header.Length, MinSizeBytes))];
|
||||
header.CopyTo(bytes, 0);
|
||||
|
||||
Mock<IFormFile> file = new();
|
||||
file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
|
||||
file.Setup(f => f.ContentType).Returns("image/png");
|
||||
file.Setup(f => f.Length).Returns(bytes.Length);
|
||||
return file;
|
||||
}
|
||||
|
||||
private static UploadPhotoCommand CommandWithFile(IFormFile file) =>
|
||||
new(Guid.NewGuid(), "breweries/some-brewery-id", file);
|
||||
|
||||
public static TheoryData<string, byte[]> ValidSignatures => new()
|
||||
{
|
||||
{ "PNG", PngSignature },
|
||||
{ "JPEG", JpegSignature },
|
||||
{ "WebP", WebPSignature }
|
||||
};
|
||||
|
||||
public static TheoryData<string, byte[]> InvalidSignatures => new()
|
||||
{
|
||||
{ "GIF", GifSignature },
|
||||
{ "BMP", BmpSignature },
|
||||
{ "PDF", PdfSignature },
|
||||
{ "plain text", PlainTextBytes }
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_FileBelowMinSize_FailsWithSizeError()
|
||||
{
|
||||
Mock<IFormFile> file = FileOfSize(MinSizeBytes - 1);
|
||||
|
||||
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "File.Length");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_FileAtMinSize_PassesSizeCheck()
|
||||
{
|
||||
Mock<IFormFile> file = FileOfSize(MinSizeBytes);
|
||||
|
||||
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
|
||||
|
||||
result.Errors.Should().NotContain(e => e.PropertyName == "File.Length");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_FileAtMaxSize_PassesSizeCheck()
|
||||
{
|
||||
Mock<IFormFile> file = FileOfSize(MaxSizeBytes);
|
||||
|
||||
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
|
||||
|
||||
result.Errors.Should().NotContain(e => e.PropertyName == "File.Length");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Validate_FileAboveMaxSize_FailsWithSizeError()
|
||||
{
|
||||
Mock<IFormFile> file = FileOfSize(MaxSizeBytes + 1);
|
||||
|
||||
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
|
||||
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "File.Length");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(ValidSignatures))]
|
||||
public async Task Validate_ValidImageSignature_PassesSignatureCheck(string formatName, byte[] header)
|
||||
{
|
||||
Mock<IFormFile> file = FileWithHeader(header);
|
||||
|
||||
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
|
||||
|
||||
result.Errors.Should().NotContain(e => e.PropertyName == "File", formatName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidSignatures))]
|
||||
public async Task Validate_InvalidImageSignature_FailsWithSignatureError(string formatName, byte[] header)
|
||||
{
|
||||
Mock<IFormFile> file = FileWithHeader(header);
|
||||
|
||||
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
|
||||
|
||||
result.IsValid.Should().BeFalse(formatName);
|
||||
result.Errors.Should().Contain(e => e.PropertyName == "File", formatName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>Features.PhotoUpload.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="FluentAssertions" Version="7.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Features.PhotoUpload\Features.PhotoUpload.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Features.ImageUploads.Commands.UploadPhoto;
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a new photo to storage under <c>{Key}/{generated photo id}</c> and records it. Sent by
|
||||
/// other features' handlers (e.g. a brewery photo upload command) rather than bound from a request
|
||||
/// directly, since this feature exposes no controller of its own.
|
||||
/// </summary>
|
||||
public record UploadPhotoCommand(Guid UploadedById, string Key, IFormFile File) : IRequest<Guid>;
|
||||
@@ -0,0 +1,34 @@
|
||||
using Domain.Entities;
|
||||
using Features.ImageUploads.Repository;
|
||||
using Infrastructure.FileUpload;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.ImageUploads.Commands.UploadPhoto;
|
||||
|
||||
/// <summary>Handles <see cref="UploadPhotoCommand" />.</summary>
|
||||
public class UploadPhotoHandler(
|
||||
IFileStorageProvider fileStorageProvider,
|
||||
IPhotoUploadRepository photoUploadRepository
|
||||
) : IRequestHandler<UploadPhotoCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(UploadPhotoCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
Guid photoId = Guid.NewGuid();
|
||||
string key = $"{request.Key}/{photoId}";
|
||||
|
||||
await using Stream stream = request.File.OpenReadStream();
|
||||
|
||||
await fileStorageProvider.UploadAsync(
|
||||
key,
|
||||
stream,
|
||||
request.File.ContentType,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
Photo photo = new() { Hyperlink = key, UploadedById = request.UploadedById };
|
||||
|
||||
await photoUploadRepository.CreateAsync(photo, cancellationToken);
|
||||
|
||||
return photoId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Features.ImageUploads.Commands.UploadPhoto;
|
||||
|
||||
/// <summary>Validates <see cref="UploadPhotoCommand" />.</summary>
|
||||
public class UploadPhotoValidator : AbstractValidator<UploadPhotoCommand>
|
||||
{
|
||||
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
private static readonly byte[] JpegSignature = [0xFF, 0xD8, 0xFF];
|
||||
|
||||
private static readonly byte[] RiffSignature =
|
||||
[
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46, // "RIFF"
|
||||
];
|
||||
|
||||
private static readonly byte[] WebPMarker =
|
||||
[
|
||||
0x57,
|
||||
0x45,
|
||||
0x42,
|
||||
0x50, // "WEBP"
|
||||
];
|
||||
|
||||
private const long MinSizeBytes = 1024; // 1 KiB
|
||||
private const long MaxSizeBytes = 24 * 1024 * 1024; // 24 MiB
|
||||
|
||||
public UploadPhotoValidator()
|
||||
{
|
||||
RuleFor(x => x.UploadedById).NotEmpty().WithMessage("UploadedById is required.");
|
||||
|
||||
RuleFor(x => x.File).NotNull().WithMessage("File is required.");
|
||||
|
||||
RuleFor(x => x.Key).NotEmpty().WithMessage("Key is required.");
|
||||
|
||||
RuleFor(x => x.File.ContentType)
|
||||
.Must(contentType =>
|
||||
contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
.WithMessage("File must be an image.");
|
||||
|
||||
RuleFor(x => x.File.Length)
|
||||
.InclusiveBetween(MinSizeBytes, MaxSizeBytes)
|
||||
.WithMessage("The file must be between 1 KiB and 24 MiB.");
|
||||
|
||||
RuleFor(x => x.File)
|
||||
.MustAsync(HaveValidImageSignatureAsync)
|
||||
.WithMessage("The file is not a valid PNG, JPEG, or WebP image.");
|
||||
}
|
||||
|
||||
private static async Task<bool> HaveValidImageSignatureAsync(
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var buffer = new byte[12];
|
||||
await using var stream = file.OpenReadStream();
|
||||
var bytesRead = await stream.ReadAsync(
|
||||
buffer.AsMemory(0, buffer.Length),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return IsPng(buffer, bytesRead) || IsJpeg(buffer, bytesRead) || IsWebP(buffer, bytesRead);
|
||||
}
|
||||
|
||||
private static bool IsPng(byte[] buffer, int bytesRead) =>
|
||||
bytesRead >= PngSignature.Length
|
||||
&& buffer.AsSpan(0, PngSignature.Length).SequenceEqual(PngSignature);
|
||||
|
||||
private static bool IsJpeg(byte[] buffer, int bytesRead) =>
|
||||
bytesRead >= JpegSignature.Length
|
||||
&& buffer.AsSpan(0, JpegSignature.Length).SequenceEqual(JpegSignature);
|
||||
|
||||
private static bool IsWebP(byte[] buffer, int bytesRead) =>
|
||||
bytesRead >= 12
|
||||
&& buffer.AsSpan(0, 4).SequenceEqual(RiffSignature)
|
||||
&& buffer.AsSpan(8, 4).SequenceEqual(WebPMarker);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Features.ImageUploads.Repository;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.ImageUploads.DependencyInjection;
|
||||
|
||||
/// <summary>Registers the services required by the PhotoUpload feature.</summary>
|
||||
public static class FeaturesPhotoUploadServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Registers <see cref="IPhotoUploadRepository" /> and its implementation.</summary>
|
||||
/// <returns><paramref name="services" />, for chaining.</returns>
|
||||
public static IServiceCollection AddFeaturesPhotoUpload(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IPhotoUploadRepository, PhotoUploadRepository>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Features.ImageUploads</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Database.Connection;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
|
||||
namespace Features.ImageUploads.Repository;
|
||||
|
||||
public interface IPhotoUploadRepository
|
||||
{
|
||||
Task CreateAsync(Photo photo, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory)
|
||||
: DapperRepository(connectionFactory),
|
||||
IPhotoUploadRepository
|
||||
{
|
||||
public async Task CreateAsync(Photo photo, CancellationToken cancellationToken)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
bool userExists =
|
||||
await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM
|
||||
dbo.UserAccount
|
||||
WHERE
|
||||
UserAccountID = @UploadedById
|
||||
""",
|
||||
new { photo.UploadedById },
|
||||
transaction
|
||||
)
|
||||
)
|
||||
is not null;
|
||||
|
||||
if (!userExists)
|
||||
throw new NotFoundException("User not found.");
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.Photo (Hyperlink, UploadedByID)
|
||||
VALUES (@Hyperlink, @UploadedById)
|
||||
""",
|
||||
new { photo.Hyperlink, photo.UploadedById },
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await RollbackQuietlyAsync(transaction);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user