diff --git a/web/backend/Biergarten.API.slnx b/web/backend/Biergarten.API.slnx
index f4ce55b6..fea462df 100644
--- a/web/backend/Biergarten.API.slnx
+++ b/web/backend/Biergarten.API.slnx
@@ -16,6 +16,7 @@
+
diff --git a/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs b/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs
index 0238734e..400a9742 100644
--- a/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs
+++ b/web/backend/Infrastructure/Infrastructure.Configuration/ConfigurationKeys.cs
@@ -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";
diff --git a/web/backend/Infrastructure/Infrastructure.FileUpload/IFileStorageProvider.cs b/web/backend/Infrastructure/Infrastructure.FileUpload/IFileStorageProvider.cs
new file mode 100644
index 00000000..106e3a60
--- /dev/null
+++ b/web/backend/Infrastructure/Infrastructure.FileUpload/IFileStorageProvider.cs
@@ -0,0 +1,29 @@
+namespace Infrastructure.FileUpload;
+
+///
+/// Service for storing and retrieving uploaded files in S3-compatible object storage.
+///
+public interface IFileStorageProvider
+{
+ /// Uploads a file to the configured bucket.
+ /// The object key (path) to store the file under.
+ /// The file content.
+ /// The MIME type of the file.
+ /// A token to cancel the upload.
+ Task UploadAsync(
+ string key,
+ Stream content,
+ string contentType,
+ CancellationToken cancellationToken = default
+ );
+
+ /// Deletes a file from the configured bucket.
+ /// The object key of the file to delete.
+ /// A token to cancel the deletion.
+ Task DeleteAsync(string key, CancellationToken cancellationToken = default);
+
+ /// Generates a time-limited URL for retrieving a file directly from storage.
+ /// The object key of the file.
+ /// How long the URL remains valid.
+ string GetPresignedUrl(string key, TimeSpan expiresIn);
+}
diff --git a/web/backend/Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj b/web/backend/Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj
new file mode 100644
index 00000000..9308d863
--- /dev/null
+++ b/web/backend/Infrastructure/Infrastructure.FileUpload/Infrastructure.FileUpload.csproj
@@ -0,0 +1,20 @@
+
+
+ net10.0
+ enable
+ enable
+ Infrastructure.FileUpload
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/backend/Infrastructure/Infrastructure.FileUpload/S3FileStorageProvider.cs b/web/backend/Infrastructure/Infrastructure.FileUpload/S3FileStorageProvider.cs
new file mode 100644
index 00000000..74d4a3ea
--- /dev/null
+++ b/web/backend/Infrastructure/Infrastructure.FileUpload/S3FileStorageProvider.cs
@@ -0,0 +1,96 @@
+using Amazon.S3;
+using Amazon.S3.Model;
+using Infrastructure.Configuration;
+using Microsoft.Extensions.Configuration;
+
+namespace Infrastructure.FileUpload;
+
+///
+/// File storage service backed by an S3-compatible object store (e.g. SeaweedFS, AWS S3).
+/// Configured via application configuration (backed by environment variables).
+///
+public class S3FileStorageProvider : IFileStorageProvider, IDisposable
+{
+ private readonly string _bucket;
+ private readonly IAmazonS3 _client;
+
+ ///
+ /// Initializes a new instance of the class, reading storage
+ /// configuration (SEAWEEDFS_SERVICE_URL, SEAWEEDFS_ACCESS_KEY_ID,
+ /// SEAWEEDFS_SECRET_ACCESS_KEY, SEAWEEDFS_BUCKET) from .
+ ///
+ ///
+ /// Thrown when any of the required configuration values is not set.
+ ///
+ 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);
+ }
+
+ ///
+ 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);
+ }
+
+ ///
+ public async Task DeleteAsync(string key, CancellationToken cancellationToken = default)
+ {
+ DeleteObjectRequest request = new() { BucketName = _bucket, Key = key };
+
+ await _client.DeleteObjectAsync(request, cancellationToken);
+ }
+
+ ///
+ public string GetPresignedUrl(string key, TimeSpan expiresIn)
+ {
+ GetPreSignedUrlRequest request = new()
+ {
+ BucketName = _bucket,
+ Key = key,
+ Expires = DateTime.UtcNow.Add(expiresIn),
+ };
+
+ return _client.GetPreSignedURL(request);
+ }
+
+ ///
+ public void Dispose()
+ {
+ _client.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}