Skip to content

Commit a683872

Browse files
committed
Add S3 storage support with Terraform updates
Introduced Amazon S3 as a storage provider alongside local storage. Added `S3StorageService` and `S3StorageOptions` for handling S3 operations, including file uploads, deletions, and public URL generation. Updated dependency injection to dynamically select storage provider based on configuration. Enhanced Terraform scripts to provision an S3 bucket with optional public read access and CloudFront distribution. Added resources for bucket ownership enforcement, public access blocking, and CloudFront origin access control. Introduced new variables for S3 configuration and updated outputs for S3 bucket and CloudFront details. Updated `appsettings.json` to include S3 configuration. Improved security by enforcing bucket ownership and disabling ACLs. Updated container images for API and Blazor services. Maintained backward compatibility with local storage as the default provider.
1 parent 1c55554 commit a683872

File tree

14 files changed

+460
-33
lines changed

14 files changed

+460
-33
lines changed

src/BuildingBlocks/Storage/Extensions.cs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
using FSH.Framework.Storage.Local;
1+
using Amazon;
2+
using Amazon.S3;
3+
using FSH.Framework.Storage.Local;
4+
using FSH.Framework.Storage.S3;
25
using FSH.Framework.Storage.Services;
6+
using Microsoft.Extensions.Configuration;
37
using Microsoft.Extensions.DependencyInjection;
48

59
namespace FSH.Framework.Storage;
@@ -11,4 +15,39 @@ public static IServiceCollection AddHeroLocalFileStorage(this IServiceCollection
1115
services.AddScoped<IStorageService, LocalStorageService>();
1216
return services;
1317
}
18+
19+
public static IServiceCollection AddHeroStorage(this IServiceCollection services, IConfiguration configuration)
20+
{
21+
var provider = configuration["Storage:Provider"]?.ToLowerInvariant();
22+
23+
if (string.Equals(provider, "s3", StringComparison.OrdinalIgnoreCase))
24+
{
25+
services.Configure<S3StorageOptions>(configuration.GetSection("Storage:S3"));
26+
27+
services.AddSingleton<IAmazonS3>(sp =>
28+
{
29+
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<S3StorageOptions>>().Value;
30+
31+
if (string.IsNullOrWhiteSpace(options.Bucket))
32+
{
33+
throw new InvalidOperationException("Storage:S3:Bucket is required when using S3 storage.");
34+
}
35+
36+
if (string.IsNullOrWhiteSpace(options.Region))
37+
{
38+
return new AmazonS3Client();
39+
}
40+
41+
return new AmazonS3Client(RegionEndpoint.GetBySystemName(options.Region));
42+
});
43+
44+
services.AddTransient<IStorageService, S3StorageService>();
45+
}
46+
else
47+
{
48+
services.AddScoped<IStorageService, LocalStorageService>();
49+
}
50+
51+
return services;
52+
}
1453
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace FSH.Framework.Storage.S3;
2+
3+
public sealed class S3StorageOptions
4+
{
5+
public string? Bucket { get; set; }
6+
public string? Region { get; set; }
7+
public string? Prefix { get; set; }
8+
public bool PublicRead { get; set; } = true;
9+
public string? PublicBaseUrl { get; set; }
10+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
using Amazon.S3;
2+
using Amazon.S3.Model;
3+
using FSH.Framework.Storage.DTOs;
4+
using FSH.Framework.Storage.Services;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.Extensions.Options;
7+
using System;
8+
using System.Text.RegularExpressions;
9+
10+
namespace FSH.Framework.Storage.S3;
11+
12+
internal sealed class S3StorageService : IStorageService
13+
{
14+
private readonly IAmazonS3 _s3;
15+
private readonly S3StorageOptions _options;
16+
private readonly ILogger<S3StorageService> _logger;
17+
18+
private const string UploadBasePath = "uploads";
19+
20+
public S3StorageService(IAmazonS3 s3, IOptions<S3StorageOptions> options, ILogger<S3StorageService> logger)
21+
{
22+
_s3 = s3;
23+
_options = options.Value ?? throw new ArgumentNullException(nameof(options));
24+
_logger = logger;
25+
26+
if (string.IsNullOrWhiteSpace(_options.Bucket))
27+
{
28+
throw new InvalidOperationException("Storage:S3:Bucket is required when using S3 storage.");
29+
}
30+
}
31+
32+
public async Task<string> UploadAsync<T>(FileUploadRequest request, FileType fileType, CancellationToken cancellationToken = default) where T : class
33+
{
34+
ArgumentNullException.ThrowIfNull(request);
35+
36+
var rules = FileTypeMetadata.GetRules(fileType);
37+
var extension = Path.GetExtension(request.FileName);
38+
39+
if (string.IsNullOrWhiteSpace(extension) || !rules.AllowedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
40+
{
41+
throw new InvalidOperationException($"File type '{extension}' is not allowed. Allowed: {string.Join(", ", rules.AllowedExtensions)}");
42+
}
43+
44+
if (request.Data.Count > rules.MaxSizeInMB * 1024 * 1024)
45+
{
46+
throw new InvalidOperationException($"File exceeds max size of {rules.MaxSizeInMB} MB.");
47+
}
48+
49+
var key = BuildKey<T>(SanitizeFileName(request.FileName));
50+
51+
using var stream = new MemoryStream(request.Data.Select(Convert.ToByte).ToArray());
52+
53+
var putRequest = new PutObjectRequest
54+
{
55+
BucketName = _options.Bucket,
56+
Key = key,
57+
InputStream = stream,
58+
ContentType = request.ContentType
59+
};
60+
61+
// Rely on bucket policy for public access; do not set ACLs to avoid conflicts with ACL-disabled buckets.
62+
await _s3.PutObjectAsync(putRequest, cancellationToken).ConfigureAwait(false);
63+
_logger.LogInformation("Uploaded file to S3 bucket {Bucket} with key {Key}", _options.Bucket, key);
64+
65+
return BuildPublicUrl(key);
66+
}
67+
68+
public async Task RemoveAsync(string path, CancellationToken cancellationToken = default)
69+
{
70+
if (string.IsNullOrWhiteSpace(path))
71+
{
72+
return;
73+
}
74+
75+
try
76+
{
77+
var key = NormalizeKey(path);
78+
await _s3.DeleteObjectAsync(_options.Bucket, key, cancellationToken).ConfigureAwait(false);
79+
}
80+
catch (Exception ex)
81+
{
82+
_logger.LogWarning(ex, "Failed to delete S3 object {Path}", path);
83+
}
84+
}
85+
86+
private string BuildKey<T>(string fileName) where T : class
87+
{
88+
var folder = Regex.Replace(typeof(T).Name.ToLowerInvariant(), @"[^a-z0-9]", "_");
89+
var relativePath = Path.Combine(UploadBasePath, folder, $"{Guid.NewGuid():N}_{fileName}").Replace("\\", "/", StringComparison.Ordinal);
90+
if (!string.IsNullOrWhiteSpace(_options.Prefix))
91+
{
92+
return $"{_options.Prefix.TrimEnd('/')}/{relativePath}";
93+
}
94+
95+
return relativePath;
96+
}
97+
98+
private string BuildPublicUrl(string key)
99+
{
100+
var safeKey = key.TrimStart('/');
101+
102+
if (!string.IsNullOrWhiteSpace(_options.PublicBaseUrl))
103+
{
104+
return $"{_options.PublicBaseUrl.TrimEnd('/')}/{safeKey}";
105+
}
106+
107+
if (!_options.PublicRead)
108+
{
109+
return key;
110+
}
111+
112+
if (string.IsNullOrWhiteSpace(_options.Region) || string.Equals(_options.Region, "us-east-1", StringComparison.OrdinalIgnoreCase))
113+
{
114+
return $"https://{_options.Bucket}.s3.amazonaws.com/{safeKey}";
115+
}
116+
117+
return $"https://{_options.Bucket}.s3.{_options.Region}.amazonaws.com/{safeKey}";
118+
}
119+
120+
private string NormalizeKey(string path)
121+
{
122+
// If a full URL was passed, strip host and query to get the object key.
123+
if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
124+
{
125+
path = uri.AbsolutePath;
126+
}
127+
128+
var trimmed = path.TrimStart('/');
129+
if (!string.IsNullOrWhiteSpace(_options.Prefix) && trimmed.StartsWith(_options.Prefix, StringComparison.OrdinalIgnoreCase))
130+
{
131+
return trimmed;
132+
}
133+
134+
if (!string.IsNullOrWhiteSpace(_options.Prefix))
135+
{
136+
return $"{_options.Prefix.TrimEnd('/')}/{trimmed}";
137+
}
138+
139+
return trimmed;
140+
}
141+
142+
private static string SanitizeFileName(string fileName)
143+
{
144+
return Regex.Replace(fileName, @"[^a-zA-Z0-9_\.-]", "_");
145+
}
146+
}

src/BuildingBlocks/Storage/Storage.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,9 @@
1616
<ItemGroup>
1717
<FrameworkReference Include="Microsoft.AspNetCore.App" />
1818
</ItemGroup>
19+
20+
<ItemGroup>
21+
<PackageReference Include="AWSSDK.S3" />
22+
</ItemGroup>
1923

2024
</Project>

src/Directory.Packages.props

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,7 @@
9494
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0" />
9595
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
9696
</ItemGroup>
97-
</Project>
97+
<ItemGroup Label="AWS">
98+
<PackageVersion Include="AWSSDK.S3" Version="3.7.405.0" />
99+
</ItemGroup>
100+
</Project>

src/Modules/Identity/Modules.Identity/IdentityModule.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using FSH.Framework.Persistence;
1010
using FSH.Framework.Storage.Local;
1111
using FSH.Framework.Storage.Services;
12+
using FSH.Framework.Storage;
1213
using FSH.Framework.Web.Modules;
1314
using FSH.Modules.Identity.Authorization;
1415
using FSH.Modules.Identity.Authorization.Jwt;
@@ -61,7 +62,7 @@ public void ConfigureServices(IHostApplicationBuilder builder)
6162
services.AddScoped(sp => (ICurrentUserInitializer)sp.GetRequiredService<ICurrentUser>());
6263
services.AddTransient<IUserService, UserService>();
6364
services.AddTransient<IRoleService, RoleService>();
64-
services.AddTransient<IStorageService, LocalStorageService>();
65+
services.AddHeroStorage(builder.Configuration);
6566
services.AddScoped<IIdentityService, IdentityService>();
6667
services.AddHeroDbContext<IdentityDbContext>();
6768
services.AddEventingCore(builder.Configuration);

src/Playground/Playground.Api/appsettings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,5 +134,12 @@
134134
},
135135
"MultitenancyOptions": {
136136
"RunTenantMigrationsOnStartup": true
137+
},
138+
"Storage": {
139+
"Provider": "s3",
140+
"S3": {
141+
"Bucket": "dev-fsh-app-bucket",
142+
"PublicBaseUrl": "https://d1rafgenord1fg.cloudfront.net"
143+
}
137144
}
138145
}

terraform/apps/playground/app_stack/main.tf

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -69,24 +69,28 @@ module "alb" {
6969
module "app_s3" {
7070
source = "../../../modules/s3_bucket"
7171

72-
name = var.app_s3_bucket_name
73-
tags = local.common_tags
72+
name = var.app_s3_bucket_name
73+
tags = local.common_tags
74+
enable_public_read = var.app_s3_enable_public_read
75+
public_read_prefix = var.app_s3_public_read_prefix
76+
enable_cloudfront = var.app_s3_enable_cloudfront
77+
cloudfront_price_class = var.app_s3_cloudfront_price_class
7478
}
7579

7680
module "rds" {
7781
source = "../../../modules/rds_postgres"
7882

79-
name = "${var.environment}-${var.region}-postgres"
80-
vpc_id = module.network.vpc_id
81-
subnet_ids = module.network.private_subnet_ids
83+
name = "${var.environment}-${var.region}-postgres"
84+
vpc_id = module.network.vpc_id
85+
subnet_ids = module.network.private_subnet_ids
8286
allowed_security_group_ids = [
8387
module.api_service.security_group_id,
8488
module.blazor_service.security_group_id,
8589
]
86-
db_name = var.db_name
87-
username = var.db_username
88-
password = var.db_password
89-
tags = local.common_tags
90+
db_name = var.db_name
91+
username = var.db_username
92+
password = var.db_password
93+
tags = local.common_tags
9094
}
9195

9296
locals {
@@ -96,14 +100,14 @@ locals {
96100
module "redis" {
97101
source = "../../../modules/elasticache_redis"
98102

99-
name = "${var.environment}-${var.region}-redis"
100-
vpc_id = module.network.vpc_id
101-
subnet_ids = module.network.private_subnet_ids
103+
name = "${var.environment}-${var.region}-redis"
104+
vpc_id = module.network.vpc_id
105+
subnet_ids = module.network.private_subnet_ids
102106
allowed_security_group_ids = [
103107
module.api_service.security_group_id,
104108
module.blazor_service.security_group_id,
105109
]
106-
tags = local.common_tags
110+
tags = local.common_tags
107111
}
108112

109113
module "api_service" {
@@ -118,9 +122,9 @@ module "api_service" {
118122
memory = var.api_memory
119123
desired_count = var.api_desired_count
120124

121-
vpc_id = module.network.vpc_id
122-
vpc_cidr_block = module.network.vpc_cidr_block
123-
subnet_ids = module.network.private_subnet_ids
125+
vpc_id = module.network.vpc_id
126+
vpc_cidr_block = module.network.vpc_cidr_block
127+
subnet_ids = module.network.private_subnet_ids
124128
assign_public_ip = false
125129

126130
listener_arn = module.alb.listener_arn
@@ -133,6 +137,12 @@ module "api_service" {
133137
ASPNETCORE_ENVIRONMENT = local.aspnetcore_environment
134138
DatabaseOptions__ConnectionString = local.db_connection_string
135139
CachingOptions__Redis = "${module.redis.primary_endpoint_address}:6379,ssl=True,abortConnect=False"
140+
OriginOptions__OriginUrl = "http://${module.alb.dns_name}"
141+
CorsOptions__AllowedOrigins__0 = "http://${module.alb.dns_name}"
142+
Storage__Provider = "s3"
143+
Storage__S3__Bucket = var.app_s3_bucket_name
144+
Storage__S3__PublicRead = false
145+
Storage__S3__PublicBaseUrl = module.app_s3.cloudfront_domain_name != "" ? "https://${module.app_s3.cloudfront_domain_name}" : ""
136146
}
137147

138148
tags = local.common_tags
@@ -150,9 +160,9 @@ module "blazor_service" {
150160
memory = var.blazor_memory
151161
desired_count = var.blazor_desired_count
152162

153-
vpc_id = module.network.vpc_id
154-
vpc_cidr_block = module.network.vpc_cidr_block
155-
subnet_ids = module.network.private_subnet_ids
163+
vpc_id = module.network.vpc_id
164+
vpc_cidr_block = module.network.vpc_cidr_block
165+
subnet_ids = module.network.private_subnet_ids
156166
assign_public_ip = false
157167

158168
listener_arn = module.alb.listener_arn
@@ -188,3 +198,11 @@ output "rds_endpoint" {
188198
output "redis_endpoint" {
189199
value = module.redis.primary_endpoint_address
190200
}
201+
202+
output "s3_bucket_name" {
203+
value = module.app_s3.bucket_name
204+
}
205+
206+
output "s3_cloudfront_domain" {
207+
value = module.app_s3.cloudfront_domain_name
208+
}

0 commit comments

Comments
 (0)