diff --git a/Dappi.HeadlessCms/Interfaces/IMediaUploadService.cs b/Dappi.HeadlessCms/Interfaces/IMediaUploadService.cs index 8004b29..c5c5db1 100644 --- a/Dappi.HeadlessCms/Interfaces/IMediaUploadService.cs +++ b/Dappi.HeadlessCms/Interfaces/IMediaUploadService.cs @@ -1,16 +1,15 @@ -using Dappi.HeadlessCms.Core.Requests; -using Dappi.HeadlessCms.Enums; -using Dappi.HeadlessCms.Models; -using Microsoft.AspNetCore.Http; - -namespace Dappi.HeadlessCms.Interfaces -{ - public interface IMediaUploadService - { - public void DeleteMedia(MediaInfo media); - Task UpdateStatusAsync(Guid mediaId, MediaUploadStatus status); - Task SaveFileAsync(Guid mediaId, StreamAndExtensionPair streamAndExtensionPair); - Task SaveFileAsync(Guid mediaId, IFormFile file); - public void ValidateFile(IFormFile file); - } -} +using Dappi.HeadlessCms.Core.Requests; +using Dappi.HeadlessCms.Enums; +using Dappi.HeadlessCms.Models; +using Microsoft.AspNetCore.Http; + +namespace Dappi.HeadlessCms.Interfaces +{ + public interface IMediaUploadService + { + public void DeleteMedia(MediaInfo media); + Task UpdateStatusAsync(Guid mediaId, MediaUploadStatus status); + Task SaveFileAsync(Guid mediaId, StreamAndExtensionPair streamAndExtensionPair); + Task SaveFileAsync(Guid mediaId, IFormFile file); + } +} diff --git a/Dappi.HeadlessCms/Models/AwsStorageOptions.cs b/Dappi.HeadlessCms/Models/AwsStorageOptions.cs index f942c21..b50e27b 100644 --- a/Dappi.HeadlessCms/Models/AwsStorageOptions.cs +++ b/Dappi.HeadlessCms/Models/AwsStorageOptions.cs @@ -4,4 +4,6 @@ public class AwsStorageOptions { public const string AwsStorage = "AWS:Storage"; public string? BucketName { get; set; } + public string? CdnUrl { get; set; } + public List? SupportedExtensions { get; set; } } diff --git a/Dappi.HeadlessCms/Services/StorageServices/AwsS3StorageService.cs b/Dappi.HeadlessCms/Services/StorageServices/AwsS3StorageService.cs index 989f159..09c4a3a 100644 --- a/Dappi.HeadlessCms/Services/StorageServices/AwsS3StorageService.cs +++ b/Dappi.HeadlessCms/Services/StorageServices/AwsS3StorageService.cs @@ -17,20 +17,24 @@ IS3ClientFactory factory ) : IMediaUploadService { private readonly IAmazonS3 _s3Client = factory.CreateClient(); + private readonly AwsStorageOptions _storageOptions = configuration + .GetSection(AwsStorageOptions.AwsStorage) + .Get()!; + private readonly AwsAccountOptions _accountOptions = configuration + .GetSection(AwsAccountOptions.AwsAccount) + .Get()!; public void DeleteMedia(MediaInfo media) { 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, + BucketName = _storageOptions.BucketName, Key = objectKey, }; @@ -49,24 +53,19 @@ public async Task SaveFileAsync(Guid mediaId, IFormFile file) public async Task SaveFileAsync(Guid mediaId, StreamAndExtensionPair streamAndExtensionPair) { - 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; - var extension = streamAndExtensionPair.Extension.StartsWith(".") ? streamAndExtensionPair.Extension : "." + streamAndExtensionPair.Extension; var objectKey = $"{mediaId}{extension}"; - var region = Amazon.RegionEndpoint.GetBySystemName(regionName ?? "eu-central-1"); + var region = Amazon.RegionEndpoint.GetBySystemName( + _accountOptions.Region ?? "eu-central-1" + ); var putRequest = new PutObjectRequest { - BucketName = bucketName, + BucketName = _storageOptions.BucketName, Key = objectKey, InputStream = streamAndExtensionPair.Stream, AutoCloseStream = true, @@ -74,9 +73,9 @@ public async Task SaveFileAsync(Guid mediaId, StreamAndExtensionPair streamAndEx }; await _s3Client.PutObjectAsync(putRequest); - var baseUrl = useCdn - ? $"{cdnUrl}/{objectKey}" - : $"https://{bucketName}.s3.{region.SystemName}.amazonaws.com/{objectKey}"; + var baseUrl = !string.IsNullOrEmpty(_storageOptions.CdnUrl) + ? $"{_storageOptions.CdnUrl}/{objectKey}" + : $"https://{_storageOptions.BucketName}.s3.{region.SystemName}.amazonaws.com/{objectKey}"; var media = await dbContext .DbContext.Set() @@ -89,17 +88,6 @@ public async Task SaveFileAsync(Guid mediaId, StreamAndExtensionPair streamAndEx } } - public void ValidateFile(IFormFile file) - { - if (file == null || file.Length == 0) - throw new Exception("No file was uploaded."); - - var fileExtension = Path.GetExtension(file.FileName); - - if (GetContentType(fileExtension) == "unsupported") - throw new Exception("Unsupported media type."); - } - public async Task UpdateStatusAsync(Guid mediaId, MediaUploadStatus status) { var media = await dbContext diff --git a/Dappi.HeadlessCms/Services/StorageServices/LocalStorageUploadService.cs b/Dappi.HeadlessCms/Services/StorageServices/LocalStorageUploadService.cs index b16c486..28c3a10 100644 --- a/Dappi.HeadlessCms/Services/StorageServices/LocalStorageUploadService.cs +++ b/Dappi.HeadlessCms/Services/StorageServices/LocalStorageUploadService.cs @@ -1,115 +1,109 @@ -using System.Net.Mime; -using Dappi.HeadlessCms.Core.Requests; -using Dappi.HeadlessCms.Enums; -using Dappi.HeadlessCms.Interfaces; -using Dappi.HeadlessCms.Models; -using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; - -namespace Dappi.HeadlessCms.Services.StorageServices -{ - public class LocalStorageUploadService(IDbContextAccessor dbContext) : IMediaUploadService - { - public void DeleteMedia(MediaInfo media) - { - if (media.Url == null) throw new ArgumentNullException(media.Url); - var filePath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", media.Url); - if (File.Exists(filePath)) File.Delete(filePath); - } - - public void ValidateFile(IFormFile file) - { - if (file == null || file.Length == 0) - throw new Exception("No file was uploaded."); - - var fileExtension = Path.GetExtension(file.FileName); - - if (GetContentType(fileExtension) == "unsupported") - throw new Exception("Unsupported media type."); - } - - public async Task SaveFileAsync(Guid mediaId, IFormFile file) - { - var uploadsFolder = Path.Combine( - Directory.GetCurrentDirectory(), - "wwwroot", - "uploads"); - - if (!Directory.Exists(uploadsFolder)) - Directory.CreateDirectory(uploadsFolder); - - var fileExtension = Path.GetExtension(file.FileName); - var fileName = $"{Guid.NewGuid()}_{mediaId}{fileExtension}"; - var filePath = Path.Combine(uploadsFolder, fileName); - - await using (var fileStream = new FileStream(filePath, FileMode.Create)) - { - await file.CopyToAsync(fileStream); - } - - var relativePath = $"uploads{Path.DirectorySeparatorChar}{fileName}"; - - var media = await dbContext.DbContext.Set() - .Where(m => m.Id == mediaId).FirstOrDefaultAsync(); - - if (media == null) return; - - media.Url = relativePath; - await dbContext.DbContext.SaveChangesAsync(); - } - - public async Task SaveFileAsync(Guid mediaId, StreamAndExtensionPair streamAndExtensionPair) - { - var uploadsFolder = Path.Combine( - Directory.GetCurrentDirectory(), - "wwwroot", - "uploads"); - - if (!Directory.Exists(uploadsFolder)) - Directory.CreateDirectory(uploadsFolder); - - var fileName = $"{Guid.NewGuid()}_{mediaId}{streamAndExtensionPair.Extension}"; - var filePath = Path.Combine(uploadsFolder, fileName); - - await using (var fileStream = new FileStream(filePath, FileMode.Create)) - { - await streamAndExtensionPair.Stream.CopyToAsync(fileStream); - } - - var relativePath = $"uploads{Path.DirectorySeparatorChar}{fileName}"; - - var media = await dbContext.DbContext.Set() - .Where(m => m.Id == mediaId).FirstOrDefaultAsync(); - - if (media == null) return; - - media.Url = relativePath; - await dbContext.DbContext.SaveChangesAsync(); - } - - public async Task UpdateStatusAsync(Guid mediaId, MediaUploadStatus status) - { - var media = await dbContext.DbContext.Set() - .Where(m => m.Id == mediaId).FirstOrDefaultAsync(); - - if (media == null) return; - - media.Status = status; - await dbContext.DbContext.SaveChangesAsync(); - } - - private string GetContentType(string fileExtension) - { - return fileExtension.ToLower() switch - { - ".pdf" => MediaTypeNames.Application.Pdf, - ".jpg" or ".jpeg" => MediaTypeNames.Image.Jpeg, - ".png" => MediaTypeNames.Image.Png, - ".gif" => MediaTypeNames.Image.Gif, - _ => "unsupported", - }; - } - - public override string ToString() => "local"; - } -} \ No newline at end of file +using System.Net.Mime; +using Dappi.HeadlessCms.Core.Requests; +using Dappi.HeadlessCms.Enums; +using Dappi.HeadlessCms.Interfaces; +using Dappi.HeadlessCms.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; + +namespace Dappi.HeadlessCms.Services.StorageServices +{ + public class LocalStorageUploadService(IDbContextAccessor dbContext) : IMediaUploadService + { + public void DeleteMedia(MediaInfo media) + { + if (media.Url == null) + throw new ArgumentNullException(media.Url); + var filePath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", media.Url); + if (File.Exists(filePath)) + File.Delete(filePath); + } + + public async Task SaveFileAsync(Guid mediaId, IFormFile file) + { + var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads"); + + if (!Directory.Exists(uploadsFolder)) + Directory.CreateDirectory(uploadsFolder); + + var fileExtension = Path.GetExtension(file.FileName); + var fileName = $"{Guid.NewGuid()}_{mediaId}{fileExtension}"; + var filePath = Path.Combine(uploadsFolder, fileName); + + await using (var fileStream = new FileStream(filePath, FileMode.Create)) + { + await file.CopyToAsync(fileStream); + } + + var relativePath = $"uploads{Path.DirectorySeparatorChar}{fileName}"; + + var media = await dbContext + .DbContext.Set() + .Where(m => m.Id == mediaId) + .FirstOrDefaultAsync(); + + if (media == null) + return; + + media.Url = relativePath; + await dbContext.DbContext.SaveChangesAsync(); + } + + public async Task SaveFileAsync(Guid mediaId, StreamAndExtensionPair streamAndExtensionPair) + { + var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "uploads"); + + if (!Directory.Exists(uploadsFolder)) + Directory.CreateDirectory(uploadsFolder); + + var fileName = $"{Guid.NewGuid()}_{mediaId}{streamAndExtensionPair.Extension}"; + var filePath = Path.Combine(uploadsFolder, fileName); + + await using (var fileStream = new FileStream(filePath, FileMode.Create)) + { + await streamAndExtensionPair.Stream.CopyToAsync(fileStream); + } + + var relativePath = $"uploads{Path.DirectorySeparatorChar}{fileName}"; + + var media = await dbContext + .DbContext.Set() + .Where(m => m.Id == mediaId) + .FirstOrDefaultAsync(); + + if (media == null) + return; + + media.Url = relativePath; + await dbContext.DbContext.SaveChangesAsync(); + } + + public async Task UpdateStatusAsync(Guid mediaId, MediaUploadStatus status) + { + var media = await dbContext + .DbContext.Set() + .Where(m => m.Id == mediaId) + .FirstOrDefaultAsync(); + + if (media == null) + return; + + media.Status = status; + await dbContext.DbContext.SaveChangesAsync(); + } + + private string GetContentType(string fileExtension) + { + return fileExtension.ToLower() switch + { + ".pdf" => MediaTypeNames.Application.Pdf, + ".jpg" or ".jpeg" => MediaTypeNames.Image.Jpeg, + ".png" => MediaTypeNames.Image.Png, + ".gif" => MediaTypeNames.Image.Gif, + _ => "unsupported", + }; + } + + public override string ToString() => "local"; + } +} diff --git a/Dappi.HeadlessCms/Validators/FileUploadRequestValidator.cs b/Dappi.HeadlessCms/Validators/FileUploadRequestValidator.cs new file mode 100644 index 0000000..ab52ef8 --- /dev/null +++ b/Dappi.HeadlessCms/Validators/FileUploadRequestValidator.cs @@ -0,0 +1,133 @@ +using Dappi.HeadlessCms.Models; +using FluentValidation; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace Dappi.HeadlessCms.Validators +{ + public class FileUploadRequest + { + public IFormFile File { get; set; } + public string FieldName { get; set; } + } + + public class FileUploadRequestValidator : AbstractValidator + { + private readonly AwsStorageOptions _storageOptions; + + private static readonly Dictionary> _fileSignatures = new() + { + { + "jpg", + new List { new byte[] { 0xFF, 0xD8, 0xFF } } + }, + { + "jpeg", + new List { new byte[] { 0xFF, 0xD8, 0xFF } } + }, + { + "png", + new List { new byte[] { 0x89, 0x50, 0x4E, 0x47 } } + }, + { + "gif", + new List { new byte[] { 0x47, 0x49, 0x46, 0x38 } } + }, + { + "bmp", + new List { new byte[] { 0x42, 0x4D } } + }, + { + "pdf", + new List { new byte[] { 0x25, 0x50, 0x44, 0x46 } } + }, + { + "doc", + new List { new byte[] { 0xD0, 0xCF, 0x11, 0xE0 } } + }, + { + "docx", + new List { new byte[] { 0x50, 0x4B, 0x03, 0x04 } } + }, + { + "xlsx", + new List { new byte[] { 0x50, 0x4B, 0x03, 0x04 } } + }, + { + "txt", + new List { new byte[] { 0xEF, 0xBB, 0xBF }, new byte[] { } } + }, + }; + + public FileUploadRequestValidator(IConfiguration configuration) + { + _storageOptions = configuration + .GetSection(AwsStorageOptions.AwsStorage) + .Get()!; + + RuleFor(x => x.File) + .Cascade(CascadeMode.Stop) + .NotNull() + .WithMessage("No file was uploaded.") + .Must(file => file.Length > 0) + .WithMessage("File is empty.") + .Must(BeAValidFile) + .WithMessage("Unsupported media type."); + + RuleFor(x => x.FieldName).NotEmpty().WithMessage("Field name is required."); + } + + private bool BeAValidFile(IFormFile? file) + { + if (file == null) + return false; + + if (!BeASupportedExtension(file)) + return false; + + return HasValidFileSignature(file); + } + + private bool BeASupportedExtension(IFormFile file) + { + if ( + _storageOptions.SupportedExtensions == null + || _storageOptions.SupportedExtensions.Count == 0 + ) + return true; + + var extension = Path.GetExtension(file.FileName)?.TrimStart('.').ToLower(); + if (string.IsNullOrEmpty(extension)) + return false; + + return _storageOptions.SupportedExtensions.Any(ext => + ext.Equals(extension, System.StringComparison.InvariantCultureIgnoreCase) + ); + } + + private bool HasValidFileSignature(IFormFile file) + { + var extension = Path.GetExtension(file.FileName).TrimStart('.').ToLower(); + if (string.IsNullOrEmpty(extension) || !_fileSignatures.ContainsKey(extension)) + return false; + + var signatures = _fileSignatures[extension]; + + using var stream = file.OpenReadStream(); + foreach (var signature in signatures) + { + if (signature.Length == 0) + return true; + + var headerBytes = new byte[signature.Length]; + stream.Seek(0, SeekOrigin.Begin); + stream.ReadExactly(headerBytes, 0, headerBytes.Length); + + if (headerBytes.SequenceEqual(signature)) + return true; + } + + return false; + } + } +} diff --git a/Dappi.SourceGenerator/Generators/ActionsGenerator.cs b/Dappi.SourceGenerator/Generators/ActionsGenerator.cs index 1b3f41d..ec30e40 100644 --- a/Dappi.SourceGenerator/Generators/ActionsGenerator.cs +++ b/Dappi.SourceGenerator/Generators/ActionsGenerator.cs @@ -420,14 +420,6 @@ public async Task UploadFile(Guid id, IFormFile file, [FromForm] UploadDate = DateTime.UtcNow }; - try { - uploadService.ValidateFile(file); - } - catch(Exception ex) - { - return BadRequest(new {message = ex.Message}); - } - property.SetValue(entity, mediaInfo); await dbContext.Set().AddAsync(mediaInfo); diff --git a/Dappi.TestEnv/appsettings.json b/Dappi.TestEnv/appsettings.json index 2866ba1..d1950bc 100644 --- a/Dappi.TestEnv/appsettings.json +++ b/Dappi.TestEnv/appsettings.json @@ -26,9 +26,9 @@ }, "AWS": { "Storage": { - "UseCdn": false, "CdnUrl": "", - "BucketName": "" + "BucketName": "", + "SupportedExtensions": ["jpg", "pdf"] }, "Account": { "AccessKey": "",