Compare commits

..

7 Commits

Author SHA1 Message Date
Aaron Po
1c2a96c6f2 Wire SeaweedFS credentials through to the api.core dev container
api.core now waits on seaweedfs and receives its service URL and credentials so the file storage provider can connect.
2026-08-28 00:34:39 -04:00
Aaron Po
d2d78b4323 Add unit tests for UploadPhotoHandler 2026-08-28 00:33:03 -04:00
Aaron Po
05882f36e4 Add PhotoUpload feature for uploading photos to file storage
Adds UploadPhotoCommand/Handler/Validator and PhotoUploadRepository, invoked via MediatR by other features' commands (e.g. a future CreateBreweryPhoto) rather than exposed through its own controller. Wires the feature and IFileStorageProvider into API.Core.
2026-08-28 00:32:53 -04:00
Aaron Po
999c94eedc Add SeaweedFS-backed file storage infrastructure
Introduces IFileStorageProvider and an S3-compatible implementation (S3FileStorageProvider) configured via new SEAWEEDFS_* configuration keys, for storing uploaded files against SeaweedFS.
2026-08-28 00:32:41 -04:00
Aaron Po
61fb906060 Add Photo domain entity 2026-08-28 00:32:26 -04:00
Aaron Po
db07d87f97 Extract shared RollbackQuietlyAsync helper into DapperRepository base class
Avoids duplicating the transaction-rollback-swallowing logic in every repository that needs it, starting with BreweryRepository.
2026-08-28 00:32:13 -04:00
Aaron Po
3d4d448fe6 Add SeaweedFS S3-compatible storage container to dev environment 2026-08-27 21:13:18 -04:00
22 changed files with 557 additions and 17 deletions

View File

@@ -56,3 +56,12 @@ SMTP_FROM_EMAIL=noreply@thebiergarten.app
SMTP_FROM_NAME=The Biergarten App
WEBSITE_BASE_URL=http://localhost:3000
# ======================
# SeaweedFS (S3) Configuration
# ======================
# Credentials and default bucket for the local SeaweedFS S3-compatible storage
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
SEAWEEDFS_ACCESS_KEY_ID=admin
SEAWEEDFS_SECRET_ACCESS_KEY=secret
SEAWEEDFS_BUCKET=my-bucket

View File

@@ -20,10 +20,12 @@
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>

View File

@@ -7,10 +7,13 @@ using Features.Breweries.Controllers;
using Features.Breweries.DependencyInjection;
using Features.Emails.DependencyInjection;
using Features.Emails.Services;
using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection;
using Features.UserManagement.Controllers;
using Features.UserManagement.DependencyInjection;
using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.FileUpload;
using Infrastructure.Jwt;
using Microsoft.OpenApi.Models;
using Shared.Application.Behaviors;
@@ -65,6 +68,7 @@ builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly);
builder.Services.AddFluentValidationAutoValidation();
// Add MediatR.
@@ -75,6 +79,7 @@ builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
@@ -93,10 +98,13 @@ builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement();
builder.Services.AddFeaturesAuth();
builder.Services.AddFeaturesEmails();
builder.Services.AddFeaturesPhotoUpload();
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
builder.Services.AddSingleton<IFileStorageProvider, S3FileStorageProvider>();
builder.Services.AddScoped<GlobalExceptionFilter>();
builder

View File

@@ -16,6 +16,7 @@
<Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" />
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
<Project Path="Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj" />
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
</Folder>
@@ -30,6 +31,8 @@
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
<Project Path="Features/Features.Locations/Features.Locations.csproj" />
<Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" />
<Project Path="Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<Project Path="Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj" />
</Folder>
<Folder Name="/Shared/">
<Project Path="Shared/Shared.Contracts/Shared.Contracts.csproj" />

View File

@@ -18,4 +18,21 @@ public abstract class DapperRepository(ISqlConnectionFactory connectionFactory)
await connection.OpenAsync();
return connection;
}
/// <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>
protected static async Task RollbackQuietlyAsync(DbTransaction transaction)
{
try
{
await transaction.RollbackAsync();
}
catch
{
// Ignore: the original exception (rethrown by the caller) is what matters here.
}
}
}

View File

@@ -0,0 +1,17 @@
namespace Domain.Entities;
public sealed class Photo
{
// PK
public Guid PhotoId { get; set; }
// FK References
public Guid UploadedById { get; set; }
// Attributes
public string? Hyperlink { get; set; }
// Audit Fields
public DateTime UploadedAt { get; set; }
public byte[]? RowVersion { get; set; }
}

View File

@@ -441,22 +441,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" />,

View File

@@ -0,0 +1,119 @@
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
);
}
}

View File

@@ -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>

View File

@@ -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>;

View File

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

View File

@@ -0,0 +1,28 @@
using FluentValidation;
namespace Features.ImageUploads.Commands.UploadPhoto;
/// <summary>Validates <see cref="UploadPhotoCommand" />.</summary>
public class UploadPhotoValidator : AbstractValidator<UploadPhotoCommand>
{
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.Length)
.GreaterThan(0)
.When(x => x.File is not null)
.WithMessage("File cannot be empty.");
RuleFor(x => x.File.ContentType)
.Must(contentType =>
contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
)
.When(x => x.File is not null)
.WithMessage("File must be an image.");
}
}

View File

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

View File

@@ -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>

View File

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

View File

@@ -22,6 +22,11 @@ public static class ConfigurationKeys
public const string SmtpFromEmail = "SMTP_FROM_EMAIL";
public const string SmtpFromName = "SMTP_FROM_NAME";
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";
public const string DbServer = "DB_SERVER";
public const string DbUser = "DB_USER";
public const string DbPassword = "DB_PASSWORD";

View File

@@ -0,0 +1,29 @@
namespace Infrastructure.FileUpload;
/// <summary>
/// Service for storing and retrieving uploaded files in S3-compatible object storage.
/// </summary>
public interface IFileStorageProvider
{
/// <summary>Uploads a file to the configured bucket.</summary>
/// <param name="key">The object key (path) to store the file under.</param>
/// <param name="content">The file content.</param>
/// <param name="contentType">The MIME type of the file.</param>
/// <param name="cancellationToken">A token to cancel the upload.</param>
Task UploadAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default
);
/// <summary>Deletes a file from the configured bucket.</summary>
/// <param name="key">The object key of the file to delete.</param>
/// <param name="cancellationToken">A token to cancel the deletion.</param>
Task DeleteAsync(string key, CancellationToken cancellationToken = default);
/// <summary>Generates a time-limited URL for retrieving a file directly from storage.</summary>
/// <param name="key">The object key of the file.</param>
/// <param name="expiresIn">How long the URL remains valid.</param>
string GetPresignedUrl(string key, TimeSpan expiresIn);
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Infrastructure.FileUpload</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.102.4" />
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="10.0.1"
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,96 @@
using Amazon.S3;
using Amazon.S3.Model;
using Infrastructure.Configuration;
using Microsoft.Extensions.Configuration;
namespace Infrastructure.FileUpload;
/// <summary>
/// File storage service backed by an S3-compatible object store (e.g. SeaweedFS, AWS S3).
/// Configured via application configuration (backed by environment variables).
/// </summary>
public class S3FileStorageProvider : IFileStorageProvider, IDisposable
{
private readonly string _bucket;
private readonly IAmazonS3 _client;
/// <summary>
/// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage
/// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>,
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>) from <paramref name="configuration" />.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when any of the required configuration values is not set.
/// </exception>
public S3FileStorageProvider(IConfiguration configuration)
{
string serviceUrl = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsServiceUrl
);
string accessKeyId = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsAccessKeyId
);
string secretAccessKey = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsSecretAccessKey
);
_bucket = ConfigurationHelpers.GetKeyOrThrow(
configuration,
ConfigurationKeys.SeaweedFsBucket
);
AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true };
_client = new AmazonS3Client(accessKeyId, secretAccessKey, config);
}
/// <inheritdoc/>
public async Task UploadAsync(
string key,
Stream content,
string contentType,
CancellationToken cancellationToken = default
)
{
PutObjectRequest request = new()
{
BucketName = _bucket,
Key = key,
InputStream = content,
ContentType = contentType,
};
await _client.PutObjectAsync(request, cancellationToken);
}
/// <inheritdoc/>
public async Task DeleteAsync(string key, CancellationToken cancellationToken = default)
{
DeleteObjectRequest request = new() { BucketName = _bucket, Key = key };
await _client.DeleteObjectAsync(request, cancellationToken);
}
/// <inheritdoc/>
public string GetPresignedUrl(string key, TimeSpan expiresIn)
{
GetPreSignedUrlRequest request = new()
{
BucketName = _bucket,
Key = key,
Expires = DateTime.UtcNow.Add(expiresIn),
};
return _client.GetPreSignedURL(request);
}
/// <inheritdoc/>
public void Dispose()
{
_client.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -1,4 +1,5 @@
services:
sqlserver:
env_file: ".env.dev"
image: mcr.microsoft.com/mssql/server:2022-latest
@@ -24,6 +25,7 @@ services:
start_period: 30s
networks:
- devnet
database.migrations:
env_file: ".env.dev"
image: database.migrations
@@ -78,6 +80,8 @@ services:
depends_on:
database.seed:
condition: service_completed_successfully
seaweedfs:
condition: service_started
build:
context: ./backend
dockerfile: API/API.Core/Dockerfile
@@ -99,30 +103,55 @@ services:
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
restart: unless-stopped
networks:
- devnet
volumes:
- nuget-cache-dev:/root/.nuget/packages
seaweedfs:
env_file: ".env.dev"
image: chrislusf/seaweedfs
container_name: dev-env-seaweedfs
ports:
- "8333:8333" # S3 API
- "8888:8888" # Filer UI
- "9333:9333" # Master UI
- "23646:23646" # Admin UI
environment:
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
volumes:
- seaweedfsdata-dev:/data
restart: unless-stopped
networks:
- devnet
mailpit:
image: axllent/mailpit:latest
container_name: dev-env-mailpit
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP server
restart: unless-stopped
environment:
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
networks:
- devnet
volumes:
sqlserverdata-dev:
driver: local
nuget-cache-dev:
driver: local
seaweedfsdata-dev:
driver: local
networks:
devnet:

View File

@@ -57,6 +57,8 @@ REFRESH_TOKEN_SECRET_VAL=$(gen_jwt_secret)
CONFIRMATION_TOKEN_SECRET_VAL=$(gen_jwt_secret)
SMTP_USERNAME_VAL="smtp_$(gen_username)"
SMTP_PASSWORD_VAL=$(gen_password)
SEAWEEDFS_ACCESS_KEY_ID_VAL=$(gen_username)
SEAWEEDFS_SECRET_ACCESS_KEY_VAL=$(gen_password)
cp "$TEMPLATE_FILE" "$OUTPUT_FILE"
@@ -68,6 +70,8 @@ sed \
-e "s|^CONFIRMATION_TOKEN_SECRET=.*|CONFIRMATION_TOKEN_SECRET=${CONFIRMATION_TOKEN_SECRET_VAL}|" \
-e "s|^SMTP_USERNAME=.*|SMTP_USERNAME=${SMTP_USERNAME_VAL}|" \
-e "s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=${SMTP_PASSWORD_VAL}|" \
-e "s|^SEAWEEDFS_ACCESS_KEY_ID=.*|SEAWEEDFS_ACCESS_KEY_ID=${SEAWEEDFS_ACCESS_KEY_ID_VAL}|" \
-e "s|^SEAWEEDFS_SECRET_ACCESS_KEY=.*|SEAWEEDFS_SECRET_ACCESS_KEY=${SEAWEEDFS_SECRET_ACCESS_KEY_VAL}|" \
"$OUTPUT_FILE" > "$TMP_FILE"
mv "$TMP_FILE" "$OUTPUT_FILE"