Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions Dappi.HeadlessCms/Interfaces/IS3ClientFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Amazon;
using Amazon.S3;
using Amazon.SimpleEmail;
using Dappi.HeadlessCms.Models;
using Microsoft.Extensions.Configuration;

namespace Dappi.HeadlessCms.Interfaces;

public interface IS3ClientFactory
{
IAmazonS3 CreateClient();
}

public class S3ClientFactory(IConfiguration configuration) : IS3ClientFactory
{
public IAmazonS3 CreateClient()
{
var accountOptions =
configuration.GetSection(AwsAccountOptions.AwsAccount).Get<AwsAccountOptions>()
?? new AwsAccountOptions();

return new AmazonS3Client(
accountOptions.AccessKey,
accountOptions.SecretKey,
RegionEndpoint.GetBySystemName(accountOptions.Region)
);
}
}
14 changes: 11 additions & 3 deletions Dappi.HeadlessCms/Interfaces/ISesClientFactory.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Amazon;
using Amazon.SimpleEmail;
using Dappi.HeadlessCms.Models;
using Microsoft.Extensions.Configuration;

namespace Dappi.HeadlessCms.Interfaces;
Expand All @@ -12,8 +14,14 @@ public class SesClientFactory(IConfiguration configuration) : ISesClientFactory
{
public IAmazonSimpleEmailService CreateClient()
{
var accessKey = configuration["AWS:SES:AccessKey"];
var secretKey = configuration["AWS:SES:SecretKey"];
return new AmazonSimpleEmailServiceClient(accessKey, secretKey);
var accountOptions =
configuration.GetSection(AwsAccountOptions.AwsAccount).Get<AwsAccountOptions>()
?? new AwsAccountOptions();

return new AmazonSimpleEmailServiceClient(
accountOptions.AccessKey,
accountOptions.SecretKey,
RegionEndpoint.GetBySystemName(accountOptions.Region)
);
}
}
2 changes: 1 addition & 1 deletion Dappi.HeadlessCms/Models/AwsAccountOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ namespace Dappi.HeadlessCms.Models;

public class AwsAccountOptions
{
public const string AwsAccount = "AWS:Account";
public string? AccessKey { get; set; }
public string? SecretKey { get; set; }
public string? Region { get; set; }
public string? BucketName { get; set; }
}
3 changes: 1 addition & 2 deletions Dappi.HeadlessCms/Models/AwsSesOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ namespace Dappi.HeadlessCms.Models;

public class AwsSesOptions
{
public string? AccessKey { get; set; }
public string? SecretKey { get; set; }
public const string AwsSes = "AWS:SES";
public string? SourceEmail { get; set; }
}
7 changes: 7 additions & 0 deletions Dappi.HeadlessCms/Models/AwsStorageOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Dappi.HeadlessCms.Models;

public class AwsStorageOptions
{
public const string AwsStorage = "AWS:Storage";
public string? BucketName { get; set; }
}
40 changes: 28 additions & 12 deletions Dappi.HeadlessCms/ServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,18 +111,26 @@ public static IServiceCollection AddS3Storage(
IConfiguration configuration
)
{
var options =
configuration.GetSection("AWS:Account").Get<AwsAccountOptions>()
var accountOptions =
configuration.GetSection(AwsAccountOptions.AwsAccount).Get<AwsAccountOptions>()
?? new AwsAccountOptions();

var validator = new AwsAccountValidator();
var result = validator.Validate(options);
var accountValidator = new AwsAccountValidator();
var accountValidation = accountValidator.Validate(accountOptions);

if (!result.IsValid)
var storageOptions =
configuration.GetSection(AwsStorageOptions.AwsStorage).Get<AwsStorageOptions>()
?? new AwsStorageOptions();

var storageValidator = new AwsStorageValidator();
var storageValidation = storageValidator.Validate(storageOptions);

if (!accountValidation.IsValid || !storageValidation.IsValid)
{
throw new ValidationException(result.Errors);
throw new ValidationException(accountValidation.Errors);
}

services.AddScoped<IS3ClientFactory, S3ClientFactory>();
services.AddScoped<IMediaUploadService, AwsS3StorageService>();
return services;
}
Expand All @@ -132,15 +140,23 @@ public static IServiceCollection AddAwsSes(
IConfiguration configuration
)
{
var options =
configuration.GetSection("AWS:SES").Get<AwsSesOptions>() ?? new AwsSesOptions();
var accountOptions =
configuration.GetSection(AwsAccountOptions.AwsAccount).Get<AwsAccountOptions>()
?? new AwsAccountOptions();

var accountValidator = new AwsAccountValidator();
var accountValidation = accountValidator.Validate(accountOptions);

var sesOptions =
configuration.GetSection(AwsSesOptions.AwsSes).Get<AwsSesOptions>()
?? new AwsSesOptions();

var validator = new AwsSesValidator();
var result = validator.Validate(options);
var sesValidator = new AwsSesValidator();
var sesValidation = sesValidator.Validate(sesOptions);

if (!result.IsValid)
if (!accountValidation.IsValid || !sesValidation.IsValid)
{
throw new ValidationException(result.Errors);
throw new ValidationException(accountValidation.Errors);
}

services.AddSingleton<ISesClientFactory, SesClientFactory>();
Expand Down
96 changes: 45 additions & 51 deletions Dappi.HeadlessCms/Services/StorageServices/AwsS3StorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,31 @@

namespace Dappi.HeadlessCms.Services.StorageServices
{
public class AwsS3StorageService(IConfiguration configuration, IDbContextAccessor dbContext)
: IMediaUploadService
public class AwsS3StorageService(
IConfiguration configuration,
IDbContextAccessor dbContext,
IS3ClientFactory factory
) : IMediaUploadService
{
private readonly IAmazonS3 _s3Client = factory.CreateClient();

public void DeleteMedia(MediaInfo media)
{
// TODO: implement deletion of objects on s3
if (string.IsNullOrEmpty(media.Url))
return;

var bucketName = configuration["AWS:Storage:BucketName"];

var uri = new Uri(media.Url);
var objectKey = Path.GetFileName(uri.LocalPath);

var deleteRequest = new DeleteObjectRequest
{
BucketName = bucketName,
Key = objectKey,
};

_s3Client.DeleteObjectAsync(deleteRequest).GetAwaiter().GetResult();
}

public async Task SaveFileAsync(Guid mediaId, IFormFile file)
Expand All @@ -30,68 +49,43 @@ public async Task SaveFileAsync(Guid mediaId, IFormFile file)

public async Task SaveFileAsync(Guid mediaId, StreamAndExtensionPair streamAndExtensionPair)
{
var accessKey = configuration["AWS:Account:AccessKey"];
var secretKey = configuration["AWS:Account:SecretKey"];
var regionName = configuration["AWS:Account:Region"];
var bucketName = configuration["AWS:Storage:BucketName"];
var cdnUrl = configuration["AWS:Storage:CdnUrl"];
var regionName = configuration["AWS:Account:Region"];

var useCdn =
bool.TryParse(configuration["AWS:Storage:UseCdn"], out var parsed) && parsed;

if (string.IsNullOrEmpty(accessKey) || string.IsNullOrEmpty(secretKey))
{
throw new Exception("AWS Credentials are missing from configuration.");
}

if (useCdn && string.IsNullOrEmpty(cdnUrl))
{
throw new Exception("Cdn Url is missing from configuration.");
}

var region = Amazon.RegionEndpoint.GetBySystemName(regionName ?? "eu-central-1");

var extension = streamAndExtensionPair.Extension.StartsWith(".")
? streamAndExtensionPair.Extension
: "." + streamAndExtensionPair.Extension;

var objectKey = $"{mediaId}{extension}";

var credentials = new Amazon.Runtime.BasicAWSCredentials(accessKey, secretKey);
using var client = new AmazonS3Client(credentials, region);
var region = Amazon.RegionEndpoint.GetBySystemName(regionName ?? "eu-central-1");

try
var putRequest = new PutObjectRequest
{
var putRequest = new PutObjectRequest
{
BucketName = bucketName,
Key = objectKey,
InputStream = streamAndExtensionPair.Stream,
AutoCloseStream = true,
ContentType = GetContentType(extension),
};

await client.PutObjectAsync(putRequest);

var baseUrl = useCdn
? $"{cdnUrl}/{objectKey}"
: $"https://{bucketName}.s3.{region.SystemName}.amazonaws.com/{objectKey}";

var media = await dbContext
.DbContext.Set<MediaInfo>()
.FirstOrDefaultAsync(m => m.Id == mediaId);

if (media != null)
{
media.Url = baseUrl;
await dbContext.DbContext.SaveChangesAsync();
}
}
catch (AmazonS3Exception e)
BucketName = bucketName,
Key = objectKey,
InputStream = streamAndExtensionPair.Stream,
AutoCloseStream = true,
ContentType = GetContentType(extension),
};
await _s3Client.PutObjectAsync(putRequest);

var baseUrl = useCdn
? $"{cdnUrl}/{objectKey}"
: $"https://{bucketName}.s3.{region.SystemName}.amazonaws.com/{objectKey}";

var media = await dbContext
.DbContext.Set<MediaInfo>()
.FirstOrDefaultAsync(m => m.Id == mediaId);

if (media != null)
{
throw new Exception(
$"Error encountered on server. Message:'{e.Message}' when writing an object",
e
);
media.Url = baseUrl;
await dbContext.DbContext.SaveChangesAsync();
}
}

Expand Down
9 changes: 7 additions & 2 deletions Dappi.HeadlessCms/Validators/AmazonSesServiceValidators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ public AwsAccountValidator()
RuleFor(x => x.AccessKey).NotEmpty();
RuleFor(x => x.SecretKey).NotEmpty();
RuleFor(x => x.Region).NotEmpty();
}
}

public class AwsStorageValidator : AbstractValidator<AwsStorageOptions>
{
public AwsStorageValidator()
{
RuleFor(x => x.BucketName).NotEmpty();
}
}
Expand All @@ -18,8 +25,6 @@ public class AwsSesValidator : AbstractValidator<AwsSesOptions>
{
public AwsSesValidator()
{
RuleFor(x => x.AccessKey).NotEmpty();
RuleFor(x => x.SecretKey).NotEmpty();
RuleFor(x => x.SourceEmail).NotEmpty().EmailAddress();
}
}
23 changes: 22 additions & 1 deletion Dappi.SourceGenerator/Generators/ActionsGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -508,8 +508,27 @@ string removeCode
return string.Empty;
}

return $$"""
var mediaInfoProperties = item
.PropertiesInfos.Where(p => p.PropertyType.Name.Contains("MediaInfo"))
.Select(p => p.PropertyName)
.ToList();

var mediaInfoDeleteCode = string.Empty;
if (mediaInfoProperties.Count > 0)
{
mediaInfoDeleteCode = string.Join(
"\n",
mediaInfoProperties.Select(prop =>
$@"if (model.{prop} != null)
{{
uploadService.DeleteMedia(model.{prop});
dbContext.Set<MediaInfo>().Remove(model.{prop});
}}"
)
);
}

return $$"""
[HttpDelete("{id}")]
{{PropagateDappiAuthorizationTags(
item.AuthorizeAttributes,
Expand All @@ -522,6 +541,8 @@ public async Task<IActionResult> Delete(Guid id)
if (model is null)
return NotFound();

{{mediaInfoDeleteCode}}

dbContext.{{item.ClassName.Pluralize()}}.Remove(model);
{{removeCode}}

Expand Down
Loading