diff --git a/Api/Controllers/EventController.cs b/Api/Controllers/EventController.cs index 4036e1159bc8232eb55b8f8a81238e8673fb265b..bb15c836d4bb12f4a6642f742fa414ea61851ca4 100644 --- a/Api/Controllers/EventController.cs +++ b/Api/Controllers/EventController.cs @@ -3,8 +3,13 @@ using BusinessLayer.DTOs.Event; using BusinessLayer.Services.EventService; using BusinessLayer.Utils.Filters; using BusinessLayer.Utils.Ordering; +using BusinessLayer.DTOs.Image; +using BusinessLayer.Services.ImageService; +using BusinessLayer.Utils.Enums; using Mapster; using Microsoft.AspNetCore.Mvc; +using MimeMapping; +using System.IO.Compression; namespace Api.Controllers { @@ -13,10 +18,12 @@ namespace Api.Controllers public class EventController : Controller { private readonly IEventService _eventService; + private readonly IImageService _imageService; - public EventController(IEventService eventService) + public EventController(IEventService eventService, IImageService imageService) { _eventService = eventService; + _imageService = imageService; } [HttpGet("{eventId:guid}")] @@ -94,5 +101,102 @@ namespace Api.Controllers return Ok(); } + + + [HttpGet] + [Route("{eventId:guid}/img")] + public async Task<IActionResult> DownloadImages([FromRoute] Guid eventId, [FromQuery] ImageType type) + { + var images = await _imageService.GetImagesAsync(eventId, OwnerEntityType.Event, type); + if (type == ImageType.Avatar) + { + var image = images.FirstOrDefault(); + + return image is not null + ? File(image.Data, MimeUtility.GetMimeMapping(image.Name)) + : NotFound(); + } + + using var memoryStream = new MemoryStream(); + using var zip = new ZipArchive(memoryStream, ZipArchiveMode.Create, true); + + foreach (var image in images) + { + var entry = zip.CreateEntry(image.Name, CompressionLevel.Fastest); + using var streamWriter = entry.Open(); + await streamWriter.WriteAsync(image.Data.AsMemory(0, image.Data.Length)); + } + + zip.Dispose(); + + return File(memoryStream.ToArray(), "application/zip", "gallery.zip"); + } + + [HttpPost] + [Route("{eventId:guid}/avatar")] + public async Task<IActionResult> UploadAvatar([FromRoute] Guid eventId, [FromForm] IFormFile avatar) + { + var extension = Path.GetExtension(avatar.FileName); + + if (extension is null || avatar.ContentType != MimeUtility.GetMimeMapping(avatar.FileName)) + { + return BadRequest("File has no extension or extension doesn't match the content type."); + } + + using var stream = new MemoryStream(); + await avatar.CopyToAsync(stream); + ImageUploadDTO image = new() + { + Data = stream.ToArray(), + EntityId = eventId, + EntityType = OwnerEntityType.Event, + ImageType = ImageType.Avatar, + Extension = extension + }; + + return await _imageService.SaveImageAsync(image) + ? CreatedAtAction(nameof(UploadAvatar), null) + : BadRequest("The provided file is not an image."); + } + + [HttpPost] + [Route("{eventId:guid}/gallery")] + public async Task<IActionResult> UploadGalleryImages([FromRoute] Guid eventId, [FromForm] List<IFormFile> images) + { + List<ImageUploadDTO> dtos = new List<ImageUploadDTO>(); + foreach (var img in images) + { + var extension = Path.GetExtension(img.FileName); + + if (extension is null || img.ContentType != MimeUtility.GetMimeMapping(img.FileName)) + { + return BadRequest("File has no extension or extension doesn't match the content type."); + } + + using var stream = new MemoryStream(); + await img.CopyToAsync(stream); + + dtos.Add(new ImageUploadDTO() + { + Data = stream.ToArray(), + EntityId = eventId, + EntityType = OwnerEntityType.Event, + ImageType = ImageType.Gallery, + Extension = extension + }); + } + return await _imageService.SaveImagesAsync([.. dtos]) + ? CreatedAtAction(nameof(UploadGalleryImages), null) + : BadRequest(); + } + + [HttpDelete] + [Route("{eventId:guid}/img")] + public async Task<IActionResult> DeleteImage([FromRoute] Guid eventId, [FromQuery] string imageName) + { + return await _imageService.RemoveImageAsync(eventId, OwnerEntityType.Event, imageName) + ? Ok() + : NotFound("The image or the restaurant does not exist."); + } } } diff --git a/Api/Controllers/RestaurantController.cs b/Api/Controllers/RestaurantController.cs index 32b38062d3dd3156746937441835326472ec47ae..cf0d4454bbbf7ac974c37b2be393d91c9ef8e429 100644 --- a/Api/Controllers/RestaurantController.cs +++ b/Api/Controllers/RestaurantController.cs @@ -1,11 +1,17 @@ +using Api.Models; +using Api.Models.Restaurant; +using BusinessLayer.DTOs.Image; +using BusinessLayer.Services.ImageService; +using BusinessLayer.Utils.Enums; using BusinessLayer.DTOs.Restaurant; using BusinessLayer.Services.RestaurantService; using BusinessLayer.Services.RestaurantMaintainerService; using BusinessLayer.Utils.Filters; using BusinessLayer.Utils.Ordering; using Microsoft.AspNetCore.Mvc; -using Api.Models.Restaurant; using Mapster; +using MimeMapping; +using System.IO.Compression; namespace Api.Controllers { @@ -13,10 +19,12 @@ namespace Api.Controllers [ApiController] public class RestaurantController( IRestaurantService restaurantService, - IRestaurantMaintainerService restaurantMaintainerService) : Controller + IRestaurantMaintainerService restaurantMaintainerService, + IImageService imageService) : Controller { private readonly IRestaurantService _restaurantService = restaurantService; private readonly IRestaurantMaintainerService _restaurantMaintainerService = restaurantMaintainerService; + private readonly IImageService _imageService = imageService; [HttpGet] [Route("{restaurantId:guid}")] @@ -120,5 +128,103 @@ namespace Api.Controllers ? Ok() : NotFound(); } + + [HttpGet] + [Route("{restaurantId:guid}/img")] + public async Task<IActionResult> DownloadImages([FromRoute] Guid restaurantId, [FromQuery] ImageType type) + { + var images = await _imageService.GetImagesAsync(restaurantId, OwnerEntityType.Restaurant, type); + if (type == ImageType.Avatar) + { + var image = images.FirstOrDefault(); + + return image is not null + ? File(image.Data, MimeUtility.GetMimeMapping(image.Name)) + : NotFound(); + } + + using var memoryStream = new MemoryStream(); + using var zip = new ZipArchive(memoryStream, ZipArchiveMode.Create, true); + + foreach (var image in images) + { + var entry = zip.CreateEntry(image.Name, CompressionLevel.Fastest); + using var streamWriter = entry.Open(); + await streamWriter.WriteAsync(image.Data.AsMemory(0, image.Data.Length)); + } + + // The zip will be invalid otherwise. + zip.Dispose(); + + return File(memoryStream.ToArray(), "application/zip", "gallery.zip"); + } + + [HttpPost] + [Route("{restaurantId:guid}/avatar")] + public async Task<IActionResult> UploadAvatar([FromRoute] Guid restaurantId, [FromForm] IFormFile avatar) + { + var extension = Path.GetExtension(avatar.FileName); + + if (extension is null || avatar.ContentType != MimeUtility.GetMimeMapping(avatar.FileName)) + { + return BadRequest("File has no extension or extension doesn't match the content type."); + } + + using var stream = new MemoryStream(); + await avatar.CopyToAsync(stream); + + ImageUploadDTO image = new() + { + Data = stream.ToArray(), + EntityId = restaurantId, + EntityType = OwnerEntityType.Restaurant, + ImageType = ImageType.Avatar, + Extension = extension + }; + + return await _imageService.SaveImageAsync(image) + ? CreatedAtAction(nameof(UploadAvatar), null) + : BadRequest("The provided file is not an image."); + } + + [HttpPost] + [Route("{restaurantId:guid}/gallery")] + public async Task<IActionResult> UploadGalleryImages([FromRoute] Guid restaurantId, [FromForm] List<IFormFile> images) + { + List<ImageUploadDTO> dtos = new List<ImageUploadDTO>(); + foreach (var img in images) + { + var extension = Path.GetExtension(img.FileName); + + if (extension is null || img.ContentType != MimeUtility.GetMimeMapping(img.FileName)) + { + return BadRequest("File has no extension or extension doesn't match the content type."); + } + + using var stream = new MemoryStream(); + await img.CopyToAsync(stream); + + dtos.Add(new ImageUploadDTO() + { + Data = stream.ToArray(), + EntityId = restaurantId, + EntityType = OwnerEntityType.Restaurant, + ImageType = ImageType.Gallery, + Extension = extension + }); + } + return await _imageService.SaveImagesAsync([.. dtos]) + ? CreatedAtAction(nameof(UploadGalleryImages), null) + : BadRequest(); + } + + [HttpDelete] + [Route("{restaurantId:guid}/img")] + public async Task<IActionResult> DeleteImage([FromRoute] Guid restaurantId, [FromQuery] string imageName) + { + return await _imageService.RemoveImageAsync(restaurantId, OwnerEntityType.Restaurant, imageName) + ? Ok() + : NotFound("The image or the restaurant does not exist."); + } } } diff --git a/Api/Controllers/ReviewController.cs b/Api/Controllers/ReviewController.cs index d87c747e1d6af34816de1e2d41c9ecc044a5cc84..b7be804792b2cbfd1cb8190738e3906608806a43 100644 --- a/Api/Controllers/ReviewController.cs +++ b/Api/Controllers/ReviewController.cs @@ -3,8 +3,14 @@ using BusinessLayer.DTOs.Review; using BusinessLayer.Services.ReviewService; using BusinessLayer.Utils.Filters; using BusinessLayer.Utils.Ordering; +using BusinessLayer.Services.ImageService; +using BusinessLayer.DTOs.Image; using Mapster; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MimeMapping; +using System.IO.Compression; +using BusinessLayer.Utils.Enums; namespace Api.Controllers { @@ -13,10 +19,12 @@ namespace Api.Controllers public class ReviewController : Controller { private readonly IReviewService _service; - public ReviewController(IReviewService service) + public readonly IImageService _imageService; + + public ReviewController(IReviewService service, IImageService imageService) { _service = service; - + _imageService = imageService; } [HttpGet] @@ -90,6 +98,69 @@ namespace Api.Controllers : NotFound("Review does not exist."); } + [HttpGet] + [Route("{reviewId:guid}/img")] + public async Task<IActionResult> DownloadImages([FromRoute] Guid reviewId) + { + + var images = await _imageService.GetImagesAsync(reviewId, OwnerEntityType.Review, ImageType.Gallery); + + using var memoryStream = new MemoryStream(); + using var zip = new ZipArchive(memoryStream, ZipArchiveMode.Create, true); + + foreach (var image in images) + { + var entry = zip.CreateEntry(image.Name, CompressionLevel.Fastest); + using var streamWriter = entry.Open(); + await streamWriter.WriteAsync(image.Data.AsMemory(0, image.Data.Length)); + } + + // The zip will be invalid otherwise. + zip.Dispose(); + + return File(memoryStream.ToArray(), "application/zip", "gallery.zip"); + } + + [HttpPost] + [Route("{reviewId:guid}/gallery")] + public async Task<IActionResult> UploadGalleryImages([FromRoute] Guid reviewId, [FromForm] List<IFormFile> images) + { + List<ImageUploadDTO> dtos = new List<ImageUploadDTO>(); + foreach (var img in images) + { + var extension = Path.GetExtension(img.FileName); + + if (extension is null || img.ContentType != MimeUtility.GetMimeMapping(img.FileName)) + { + return BadRequest("File has no extension or extension doesn't match the content type."); + } + + using var stream = new MemoryStream(); + await img.CopyToAsync(stream); + + dtos.Add(new ImageUploadDTO() + { + Data = stream.ToArray(), + EntityId = reviewId, + EntityType = OwnerEntityType.Review, + ImageType = ImageType.Gallery, + Extension = extension + }); + } + return await _imageService.SaveImagesAsync([.. dtos]) + ? CreatedAtAction(nameof(UploadGalleryImages), null) + : BadRequest(); + } + + [HttpDelete] + [Route("{reviewId:guid}/img")] + public async Task<IActionResult> DeleteImage([FromRoute] Guid reviewId, [FromQuery] string imageName) + { + return await _imageService.RemoveImageAsync(reviewId, OwnerEntityType.Review, imageName) + ? Ok() + : NotFound("The image or the review does not exist."); + } + private static bool UpdateEmpty(ReviewUpdateModel data) { return data.Content is null && data.FoodRating is null diff --git a/Api/Controllers/UserController.cs b/Api/Controllers/UserController.cs index 2a96bc4a7b7bb9d03cf5f735f0c33ea4166f9041..3b2b91f8591246bdbfe8d9d5d75b087b7ed6b4fa 100644 --- a/Api/Controllers/UserController.cs +++ b/Api/Controllers/UserController.cs @@ -1,18 +1,24 @@ -using Api.Models.User; +using Api.Models; +using Api.Models.User; +using BusinessLayer.DTOs.Image; +using BusinessLayer.Services.ImageService; +using BusinessLayer.Utils.Enums; using BusinessLayer.DTOs.User; using BusinessLayer.Services.UserService; using BusinessLayer.Utils.Filters; using BusinessLayer.Utils.Ordering; using Mapster; using Microsoft.AspNetCore.Mvc; +using MimeMapping; namespace Api.Controllers { [Route("/api/[controller]")] [ApiController] - public class UserController(IUserService userService) : Controller + public class UserController(IUserService userService, IImageService imageService) : Controller { private readonly IUserService _userService = userService; + private readonly IImageService _imageService = imageService; [HttpGet] [Route("{userId:guid}")] @@ -94,5 +100,55 @@ namespace Api.Controllers ? NotFound("User not found.") : Ok(); } + + [HttpGet] + [Route("{userId:guid}/img")] + public async Task<IActionResult> DownloadImages([FromRoute] Guid userId) + { + // User only has an avatar image. + var images = await _imageService.GetImagesAsync(userId, OwnerEntityType.User, ImageType.Avatar); + + var image = images.FirstOrDefault(); + + return image is not null + ? File(image.Data, MimeUtility.GetMimeMapping(image.Name)) + : NotFound(); + } + + [HttpPost] + [Route("{userId:guid}/avatar")] + public async Task<IActionResult> UploadAvatar([FromRoute] Guid userId, [FromForm] IFormFile avatar) + { + var extension = Path.GetExtension(avatar.FileName); + + if (extension is null || avatar.ContentType != MimeUtility.GetMimeMapping(avatar.FileName)) + { + return BadRequest("File has no extension or extension doesn't match the content type."); + } + + using var stream = new MemoryStream(); + await avatar.CopyToAsync(stream); + ImageUploadDTO image = new() + { + Data = stream.ToArray(), + EntityId = userId, + EntityType = OwnerEntityType.User, + ImageType = ImageType.Avatar, + Extension = extension + }; + + return await _imageService.SaveImageAsync(image) + ? CreatedAtAction(nameof(UploadAvatar), null) + : BadRequest("The provided file is not an image."); + } + + [HttpDelete] + [Route("{userId:guid}/img")] + public async Task<IActionResult> DeleteImage([FromRoute] Guid userId, [FromQuery] string imageName) + { + return await _imageService.RemoveImageAsync(userId, OwnerEntityType.User, imageName) + ? Ok() + : NotFound("The image or the review does not exist."); + } } } diff --git a/Api/Program.cs b/Api/Program.cs index 3faee96c7c84b74a3404be3ab9e57bc8478b45f2..8866be6498afd6acc0485e1a56e8cffb418aeb12 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -6,7 +6,10 @@ using BusinessLayer.Services.UserService; using BusinessLayer.Services.ReviewAggregateService; using BusinessLayer.Services.ReviewCommentService; using BusinessLayer.Services.ReviewService; +using BusinessLayer.Services.ImageService; using DAL.Data; +using DAL.ImagePersistence.ImageStorage; +using DAL.ImagePersistence.StorageRollbackManager; using Microsoft.EntityFrameworkCore; using Microsoft.OpenApi.Models; using Elastic.Extensions.Logging; @@ -41,6 +44,10 @@ builder.Services.AddScoped<IReviewService, ReviewService>(); builder.Services.AddScoped<IReviewAggregateResultService, ReviewAggregateResultService>(); builder.Services.AddScoped<IReviewCommentService, ReviewCommentService>(); +builder.Services.AddTransient<IStorageRollbackManager, SimpleRollbackManager>(); +builder.Services.AddScoped<IImageStorage, FileSystemImageStorage>(); +builder.Services.AddScoped<IImageService, ImageService>(); + builder.Services.AddControllers(); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle diff --git a/BusinessLayer/DTOs/Image/ImageDTO.cs b/BusinessLayer/DTOs/Image/ImageDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..2b8b93227cf1c98b62e646b5c8a48622147dff9a --- /dev/null +++ b/BusinessLayer/DTOs/Image/ImageDTO.cs @@ -0,0 +1,10 @@ +namespace BusinessLayer.DTOs.Image +{ + public record ImageDTO + { + public byte[] Data { get; set; } = []; + public required string Name { get; set; } + public required string ImageType { get; set; } + public required string Extension { get; set; } + } +} diff --git a/BusinessLayer/DTOs/Image/ImageUploadDTO.cs b/BusinessLayer/DTOs/Image/ImageUploadDTO.cs new file mode 100644 index 0000000000000000000000000000000000000000..b8d000711efd7c6af69c58f17728a911003e8f0b --- /dev/null +++ b/BusinessLayer/DTOs/Image/ImageUploadDTO.cs @@ -0,0 +1,13 @@ +using BusinessLayer.Utils.Enums; + +namespace BusinessLayer.DTOs.Image +{ + public record ImageUploadDTO + { + public byte[] Data { get; set; } = []; + public Guid EntityId { get; set; } + public OwnerEntityType EntityType { get; set; } + public ImageType ImageType { get; set; } + public required string Extension { get; set; } + } +} \ No newline at end of file diff --git a/BusinessLayer/Services/ImageService/IImageService.cs b/BusinessLayer/Services/ImageService/IImageService.cs new file mode 100644 index 0000000000000000000000000000000000000000..4716e5eac886cfce12a28cfad6d2c4e4b64bc9c8 --- /dev/null +++ b/BusinessLayer/Services/ImageService/IImageService.cs @@ -0,0 +1,17 @@ +using BusinessLayer.DTOs.Image; +using BusinessLayer.Utils.Enums; + +namespace BusinessLayer.Services.ImageService +{ + public interface IImageService + { + public string[] GetImagePaths(Guid entityId, OwnerEntityType entityType, ImageType imageType); + public Task<List<ImageDTO>> GetImagesAsync(Guid entityId, OwnerEntityType entityType, ImageType imageType); + + public Task<bool> SaveImageAsync(ImageUploadDTO imageUploadDTO, bool checkEntityExistence = true); + + public Task<bool> SaveImagesAsync(ImageUploadDTO[] imageUploadDTOs); + + public Task<bool> RemoveImageAsync(Guid entityId, OwnerEntityType entityType, string imageName); + } +} diff --git a/BusinessLayer/Services/ImageService/ImageService.cs b/BusinessLayer/Services/ImageService/ImageService.cs new file mode 100644 index 0000000000000000000000000000000000000000..beed2c5e45a5fb1be54490ce13340db483e9aeb0 --- /dev/null +++ b/BusinessLayer/Services/ImageService/ImageService.cs @@ -0,0 +1,157 @@ +using BusinessLayer.DTOs.Image; +using BusinessLayer.Utils.Enums; +using DAL.Constants; +using DAL.Data; +using DAL.ImagePersistence.ImageStorage; +using DAL.ImagePersistence.Model; +using Mapster; +using Microsoft.EntityFrameworkCore; + +namespace BusinessLayer.Services.ImageService +{ + public class ImageService : IImageService + { + private readonly RestaurantDBContext _dbContext; + private readonly IImageStorage _imageStorage; + + public ImageService(RestaurantDBContext dbContext, IImageStorage imageStorage) + { + _dbContext = dbContext; + _imageStorage = imageStorage; + } + + public async Task<List<ImageDTO>> GetImagesAsync(Guid entityId, OwnerEntityType entityType, ImageType imageType) + { + if (!await EntityExists(entityId, entityType)) + { + return []; + } + + var nameFilter = imageType == ImageType.All + ? "*" + : imageType.ToString("g") + "*"; + + var images = await _imageStorage.ReadImagesAsync(new BaseImagePathData + { + OwnerEntityName = entityType.ToString("g"), + OwnerEntityId = entityId.ToString(), + ImageType = imageType.ToString("g") + }, + nameFilter); + + TypeAdapterConfig<LoadedImageData, ImageDTO> + .NewConfig() + .Map(dest => dest.Name, src => Path.GetFileName(src.Path)); + + return images.Select(image => image.Adapt<ImageDTO>()).ToList(); + } + + public async Task<bool> SaveImageAsync(ImageUploadDTO imageUploadDTO, bool checkEntityExistence = true) + { + if (checkEntityExistence && !await EntityExists(imageUploadDTO.EntityId, imageUploadDTO.EntityType)) + { + return false; + } + + bool replace = imageUploadDTO.ImageType == ImageType.Avatar; + + return await _imageStorage.SaveImageAsync( + new ImageStorageData + { + Data = imageUploadDTO.Data, + Path = new GenericImagePathData + { + OwnerEntityName = imageUploadDTO.EntityType.ToString("g"), + OwnerEntityId = imageUploadDTO.EntityId.ToString(), + ImageType = imageUploadDTO.ImageType.ToString("g"), + Extension = imageUploadDTO.Extension, + } + }, + replace); + } + + public async Task<bool> SaveImagesAsync(ImageUploadDTO[] imageUploadDTOs) + { + var first = imageUploadDTOs.First(); + if (!await EntityExists(first.EntityId, first.EntityType)) + { + return false; + } + + TypeAdapterConfig<ImageUploadDTO, ImageStorageData> + .NewConfig() + .Map(dest => dest.Path, + src => new GenericImagePathData + { + OwnerEntityName = src.EntityType.ToString("g"), + OwnerEntityId = src.EntityId.ToString(), + ImageType = src.ImageType.ToString("g"), + Extension = src.Extension, + }); + + return await _imageStorage.SaveImagesAsync( + imageUploadDTOs + .Select(i => i.Adapt<ImageStorageData>()) + .ToList(), + false); + } + + private async Task<bool> EntityExists(Guid entityId, OwnerEntityType entityType) + { + return entityType switch + { + OwnerEntityType.User => await _dbContext.Users + .AnyAsync(u => u.Id == entityId && u.DeletedAt == null), + + OwnerEntityType.Restaurant => await _dbContext.Restaurants + .AnyAsync(r => r.Id == entityId && r.DeletedAt == null), + + OwnerEntityType.Event => await _dbContext.Events + .AnyAsync(e => e.Id == entityId && e.DeletedAt == null), + + OwnerEntityType.Review => await _dbContext.Reviews + .AnyAsync(r => r.Id == entityId && r.DeletedAt == null), + + _ => false, + }; + } + + public async Task<bool> RemoveImageAsync(Guid entityId, OwnerEntityType entityType, string imageName) + { + if (!await EntityExists(entityId, entityType)) + { + return false; + } + + return _imageStorage.RemoveImage(new SpecificImagePathData + { + OwnerEntityName = entityType.ToString("g"), + OwnerEntityId = entityId.ToString(), + ImageType = ImageType.All.ToString("g"), + Name = imageName + }); + } + + public string[] GetImagePaths(Guid entityId, OwnerEntityType entityType, ImageType imageType) + { + return _imageStorage.GetImagePaths(new BaseImagePathData + { + OwnerEntityName = entityType.ToString("g"), + OwnerEntityId = entityId.ToString(), + ImageType = ImageType.All.ToString("g") + }, + ComposeNameFilter(imageType)); + + } + + private string ComposeNameFilter(ImageType type) + { + return type switch + { + ImageType.Avatar => Paths.AvatarImageName + "*", + ImageType.Gallery => Paths.GalleryImageName + "*", + _ => "*" + }; + } + } +} diff --git a/BusinessLayer/Utils/Enums/ImageType.cs b/BusinessLayer/Utils/Enums/ImageType.cs new file mode 100644 index 0000000000000000000000000000000000000000..c3e687097fa93558ef60cd927a7d28255265d53b --- /dev/null +++ b/BusinessLayer/Utils/Enums/ImageType.cs @@ -0,0 +1,9 @@ +namespace BusinessLayer.Utils.Enums +{ + public enum ImageType + { + Avatar, + Gallery, + All + } +} diff --git a/BusinessLayer/Utils/Enums/OwnerEntityType.cs b/BusinessLayer/Utils/Enums/OwnerEntityType.cs new file mode 100644 index 0000000000000000000000000000000000000000..d3f2875eef3bf0f873df8e1aa9763d164a68d049 --- /dev/null +++ b/BusinessLayer/Utils/Enums/OwnerEntityType.cs @@ -0,0 +1,10 @@ +namespace BusinessLayer.Utils.Enums +{ + public enum OwnerEntityType + { + User, + Event, + Restaurant, + Review + } +} diff --git a/DAL/Constants/Paths.cs b/DAL/Constants/Paths.cs new file mode 100644 index 0000000000000000000000000000000000000000..3e847a447de9ab2459d5c58889f4b81eefea7458 --- /dev/null +++ b/DAL/Constants/Paths.cs @@ -0,0 +1,11 @@ +namespace DAL.Constants +{ + public static class Paths + { + public const string ImageDirPath = "img"; + public const string AvatarImageName = "Avatar"; + public const string GalleryImageName = "Gallery"; + public const string ImageMimePrefix = "image"; + public const string ImageIdSeparator = "-"; + } +} diff --git a/DAL/DAL.csproj b/DAL/DAL.csproj index 767c3ca4ff77640a5dc14f45c5dcc041b1a22c74..1b0623f76daa1070ff88b9612a193543f974be28 100644 --- a/DAL/DAL.csproj +++ b/DAL/DAL.csproj @@ -14,7 +14,8 @@ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" /> - </ItemGroup> + <PackageReference Include="MimeMapping" Version="3.0.1" /> + </ItemGroup> <ItemGroup> <Folder Include="Migrations\" /> diff --git a/DAL/ImagePersistence/ImageStorage/FileSystemImageStorage.cs b/DAL/ImagePersistence/ImageStorage/FileSystemImageStorage.cs new file mode 100644 index 0000000000000000000000000000000000000000..a25edb5378f55ae63d5bc8b95af7f6475138784c --- /dev/null +++ b/DAL/ImagePersistence/ImageStorage/FileSystemImageStorage.cs @@ -0,0 +1,213 @@ +using DAL.Constants; +using DAL.ImagePersistence.Model; +using DAL.ImagePersistence.StorageRollbackManager; +using DAL.ImagePersistence.Utils; + +namespace DAL.ImagePersistence.ImageStorage +{ + public class FileSystemImageStorage : IImageStorage + { + public readonly IStorageRollbackManager _rollbackManager; + + public FileSystemImageStorage(IStorageRollbackManager rollbackManager) + { + _rollbackManager = rollbackManager; + } + + public string[] GetImagePaths(BaseImagePathData path, string nameFilter) + { + return Directory.GetFiles(path.ComposeDirectoryPath(), nameFilter); + } + + public async Task<List<LoadedImageData>> ReadImagesAsync(BaseImagePathData path, string nameFilter) + { + var dirPath = path.ComposeDirectoryPath(); + + if (!Directory.Exists(dirPath)) + { + return []; + } + + var images = Directory.GetFiles(dirPath, nameFilter); + + List<LoadedImageData> result = []; + + foreach (var imagePath in images) + { + var mime = MimeMapping.MimeUtility.GetMimeMapping(imagePath); + if (mime == null || !mime.StartsWith(Paths.ImageMimePrefix)) + { + continue; + } + + var image = await LoadImage(imagePath, mime); + if (image != null) + { + result.Add(image); + } + } + + return result; + } + + public async Task<LoadedImageData?> ReadImageAsync(SpecificImagePathData path) + { + var imagePath = path.ComposeSpecificFilePath(); + + if (!File.Exists(imagePath)) + { + return null; + } + + var mime = MimeMapping.MimeUtility.GetMimeMapping(imagePath); + if (mime == null || !mime.StartsWith(Paths.ImageMimePrefix)) + { + return null; + } + + return await LoadImage(imagePath, mime); + } + + public bool RemoveImage(SpecificImagePathData path) + { + string filePath = path.ComposeSpecificFilePath(); + + if (!File.Exists(filePath)) + { + return false; + } + + File.Delete(filePath); + + return true; + } + + public async Task<bool> SaveImagesAsync(List<ImageStorageData> images, bool replace) + { + var first = images.FirstOrDefault(); + + if (first == null) + { + // The list is empty -> we saved all images :-) + return true; + } + + _ = Directory.CreateDirectory(first.Path.ComposeDirectoryPath()); + + /* + * Precalculating the expected ID for each image so we don't have to + * resolve the new ID every time we save an image. The check for duplication + * still occurs. + */ + int? expectedId = first.Path.PrefetchExpectedIdentifier(replace); + + if (expectedId == null || expectedId < 0) + { + return false; + } + + foreach (var image in images) + { + image.Path.Identifier = expectedId; + expectedId++; + + if (!await StoreImage(image, replace, true)) + { + return false; + } + } + + return true; + } + + public async Task<bool> SaveImageAsync(ImageStorageData image, bool replace) + { + _ = Directory.CreateDirectory(image.Path.ComposeDirectoryPath()); + + return await StoreImage(image, replace, false); + } + + private static async Task<LoadedImageData> LoadImage(string path, string mime) + { + using var file = File.OpenRead(path); + byte[] data = new byte[file.Length]; + await file.ReadAsync(data); + + return new LoadedImageData + { + Path = path, + Data = data, + Mime = mime + }; + } + + private async Task<bool> StoreImage(ImageStorageData image, bool replace, bool useRollback) + { + string? imagePath; + + try + { + imagePath = image.Path.ResolveImagePath(replace); + } + catch (Exception) + { + if (useRollback) + { + await _rollbackManager.RollbackAsync(); + } + throw; + } + + if (imagePath is null) + { + if (useRollback) + { + await _rollbackManager.RollbackAsync(); + } + return false; + } + + try + { + return await WriteFile(image, replace, useRollback, imagePath); + } + catch (Exception) + { + if (useRollback) + { + await _rollbackManager.RollbackAsync(); + } + throw; + } + } + + private async Task<bool> WriteFile(ImageStorageData image, + bool replace, bool useRollback, string imagePath) + { + bool isOverwrite = replace && File.Exists(imagePath); + + using var file = File.Open(imagePath, FileMode.OpenOrCreate); + + if (useRollback) + { + byte[] contents = new byte[file.Length]; + + if (isOverwrite) + { + await file.ReadAsync(contents); + file.Seek(0, SeekOrigin.Begin); + } + + _rollbackManager.AddEntry(new RollbackEntry + { + Path = imagePath, + OriginalData = isOverwrite ? contents : [] + }); + } + + await file.WriteAsync(image.Data); + _rollbackManager.Commit(); + return true; + } + } +} diff --git a/DAL/ImagePersistence/ImageStorage/IImageStorage.cs b/DAL/ImagePersistence/ImageStorage/IImageStorage.cs new file mode 100644 index 0000000000000000000000000000000000000000..d39dcacfc0355ac45b8c2be2b7de71b948169118 --- /dev/null +++ b/DAL/ImagePersistence/ImageStorage/IImageStorage.cs @@ -0,0 +1,20 @@ +using DAL.ImagePersistence.Model; + +namespace DAL.ImagePersistence.ImageStorage +{ + public interface IImageStorage + { + public string[] GetImagePaths(BaseImagePathData path, string nameFilter); + + public Task<LoadedImageData?> ReadImageAsync(SpecificImagePathData path); + + public Task<bool> SaveImageAsync(ImageStorageData image, bool replace); + + public Task<bool> SaveImagesAsync(List<ImageStorageData> image, bool replace); + + public Task<List<LoadedImageData>> ReadImagesAsync(BaseImagePathData path, + string nameFilter); + + public bool RemoveImage(SpecificImagePathData path); + } +} diff --git a/DAL/ImagePersistence/Model/BaseImagePathData.cs b/DAL/ImagePersistence/Model/BaseImagePathData.cs new file mode 100644 index 0000000000000000000000000000000000000000..41211c8aabee0caaf17c82a8a9bac843d5b34fe7 --- /dev/null +++ b/DAL/ImagePersistence/Model/BaseImagePathData.cs @@ -0,0 +1,9 @@ +namespace DAL.ImagePersistence.Model +{ + public class BaseImagePathData + { + public required string OwnerEntityName { get; set; } + public required string OwnerEntityId { get; set; } + public required string ImageType { get; set; } + } +} diff --git a/DAL/ImagePersistence/Model/GenericImagePathData.cs b/DAL/ImagePersistence/Model/GenericImagePathData.cs new file mode 100644 index 0000000000000000000000000000000000000000..0f3351f6449dabd278840637668a92f47ea3fd13 --- /dev/null +++ b/DAL/ImagePersistence/Model/GenericImagePathData.cs @@ -0,0 +1,8 @@ +namespace DAL.ImagePersistence.Model +{ + public class GenericImagePathData : BaseImagePathData + { + public required string Extension { get; set; } + public int? Identifier { get; set; } + } +} diff --git a/DAL/ImagePersistence/Model/ImageStorageData.cs b/DAL/ImagePersistence/Model/ImageStorageData.cs new file mode 100644 index 0000000000000000000000000000000000000000..51adc5518255fdb8fc7913af4920436211ef5a90 --- /dev/null +++ b/DAL/ImagePersistence/Model/ImageStorageData.cs @@ -0,0 +1,9 @@ +namespace DAL.ImagePersistence.Model +{ + public class ImageStorageData + { + public byte[] Data { get; set; } = []; + public required GenericImagePathData Path { get; set; } + public string? Mime { get; set; } + } +} diff --git a/DAL/ImagePersistence/Model/LoadedImageData.cs b/DAL/ImagePersistence/Model/LoadedImageData.cs new file mode 100644 index 0000000000000000000000000000000000000000..faf87786a8919903179e07cb0cc1f45b45f23147 --- /dev/null +++ b/DAL/ImagePersistence/Model/LoadedImageData.cs @@ -0,0 +1,9 @@ +namespace DAL.ImagePersistence.Model +{ + public class LoadedImageData + { + public byte[] Data { get; set; } = []; + public required string Path { get; set; } + public string? Mime { get; set; } + } +} diff --git a/DAL/ImagePersistence/Model/RollbackEntry.cs b/DAL/ImagePersistence/Model/RollbackEntry.cs new file mode 100644 index 0000000000000000000000000000000000000000..e06fc06977aecc305dfc3d45b687351a9b478186 --- /dev/null +++ b/DAL/ImagePersistence/Model/RollbackEntry.cs @@ -0,0 +1,8 @@ +namespace DAL.ImagePersistence.Model +{ + public class RollbackEntry + { + public required string Path { get; set; } + public byte[] OriginalData { get; set; } = []; + } +} diff --git a/DAL/ImagePersistence/Model/SpecificImagePathData.cs b/DAL/ImagePersistence/Model/SpecificImagePathData.cs new file mode 100644 index 0000000000000000000000000000000000000000..46bbd6a81b433583bd6cb4a7ca8c7ae6584b807d --- /dev/null +++ b/DAL/ImagePersistence/Model/SpecificImagePathData.cs @@ -0,0 +1,7 @@ +namespace DAL.ImagePersistence.Model +{ + public class SpecificImagePathData : BaseImagePathData + { + public required string Name { get; set; } + } +} diff --git a/DAL/ImagePersistence/StorageRollbackManager/IStorageRollbackManager.cs b/DAL/ImagePersistence/StorageRollbackManager/IStorageRollbackManager.cs new file mode 100644 index 0000000000000000000000000000000000000000..9dcc3585defbc6257e6f6cb54d8c334ad983c944 --- /dev/null +++ b/DAL/ImagePersistence/StorageRollbackManager/IStorageRollbackManager.cs @@ -0,0 +1,13 @@ +using DAL.ImagePersistence.Model; + +namespace DAL.ImagePersistence.StorageRollbackManager +{ + public interface IStorageRollbackManager + { + public void AddEntry(RollbackEntry entry); + public void Rollback(); + public Task RollbackAsync(); + public void Commit(); + public Task CommitAsync(); + } +} diff --git a/DAL/ImagePersistence/StorageRollbackManager/SimpleRollbackManager.cs b/DAL/ImagePersistence/StorageRollbackManager/SimpleRollbackManager.cs new file mode 100644 index 0000000000000000000000000000000000000000..ef1483b8755a739a5c6076d641a7c0fef895de57 --- /dev/null +++ b/DAL/ImagePersistence/StorageRollbackManager/SimpleRollbackManager.cs @@ -0,0 +1,68 @@ +using DAL.ImagePersistence.Model; + +namespace DAL.ImagePersistence.StorageRollbackManager +{ + public class SimpleRollbackManager : IStorageRollbackManager + { + private readonly List<RollbackEntry> _rollbackEntries = []; + + public SimpleRollbackManager() { } + + public void AddEntry(RollbackEntry entry) + { + _rollbackEntries.Add(entry); + } + + public void Commit() + { + _rollbackEntries.Clear(); + } + + // This is redundant, but future implementations may do something that requires async <.< + public Task CommitAsync() + { + _rollbackEntries.Clear(); + return Task.CompletedTask; + } + + public void Rollback() + { + foreach (var entry in _rollbackEntries) + { + // Checking for exceptions here is a little ... uncertain. + if (entry.OriginalData.Length == 0) + { + File.Delete(entry.Path); + } + else + { + using FileStream stream = File.Open(entry.Path, FileMode.Truncate); + + stream.Write(entry.OriginalData); + } + } + + _rollbackEntries.Clear(); + } + + public async Task RollbackAsync() + { + foreach (var entry in _rollbackEntries) + { + // Checking for exceptions here is a little ... uncertain. + if (entry.OriginalData.Length == 0) + { + File.Delete(entry.Path); + } + else + { + using FileStream stream = File.Open(entry.Path, FileMode.Truncate); + + await stream.WriteAsync(entry.OriginalData); + } + } + + _rollbackEntries.Clear(); + } + } +} diff --git a/DAL/ImagePersistence/Utils/FileNameManager.cs b/DAL/ImagePersistence/Utils/FileNameManager.cs new file mode 100644 index 0000000000000000000000000000000000000000..60d8b6781e7406b75339892262013f7104b769a9 --- /dev/null +++ b/DAL/ImagePersistence/Utils/FileNameManager.cs @@ -0,0 +1,130 @@ +using DAL.Constants; +using DAL.ImagePersistence.Model; + +namespace DAL.ImagePersistence.Utils +{ + public static class FileNameManager + { + public static int? PrefetchExpectedIdentifier(this GenericImagePathData path, bool replace) + { + string? fullPath = path.ResolveImagePath(replace); + + if (fullPath == null) + { + return null; + } + + return fullPath.RetrieveImageIdentifier(); + } + + public static string? ResolveImagePath(this GenericImagePathData path, bool replace) + { + var genericPath = path.ComposeGenericFilePath(); + + if (File.Exists(genericPath) && !replace) + { + if (path.ImageType.Equals(Paths.AvatarImageName, StringComparison.InvariantCultureIgnoreCase)) + { + return null; + } + + var name = AddIdentifierToName(genericPath); + var directory = Path.GetDirectoryName(genericPath); + + return Path.Join(directory, name); + } + + return genericPath; + } + + public static string ComposeDirectoryPath(this BaseImagePathData path) + { + return Path.Combine( + GetRootDirectory(), + Paths.ImageDirPath, + path.OwnerEntityName, + path.OwnerEntityId); + } + + public static string ComposeSpecificFilePath(this SpecificImagePathData path) + { + return Path.Combine( + GetRootDirectory(), + Paths.ImageDirPath, + path.OwnerEntityName, + path.OwnerEntityId, + path.Name); + } + + /** + * Creates the default path for an image of a certain type. + * For avatar, this would be "avatar.extension", for gallery this + * would be "gallery.extension", etc. + */ + public static string ComposeGenericFilePath(this GenericImagePathData path) + { + var identifier = path.Identifier is null || path.Identifier == 0 + ? string.Empty + : Paths.ImageIdSeparator + path.Identifier; + + var imageName = path.ImageType + identifier + path.Extension; + + return Path.Combine( + GetRootDirectory(), + Paths.ImageDirPath, + path.OwnerEntityName, + path.OwnerEntityId, + imageName); + } + + private static string GetRootDirectory() + { + return Directory + .GetParent(Directory.GetCurrentDirectory())! + .FullName; + } + + /** + * Takes the generic file path for a certain image type and + * adds a numeric identifier into the name, giving the image + * its own specific name. + * Example: generic path - "/gallery.png" + * specific path: - "/gallery-1.png" + */ + public static string AddIdentifierToName(string name) + { + var extension = Path.GetExtension(name) ?? throw new ArgumentException("Provided image path has no extension."); + var fileName= Path.GetFileNameWithoutExtension(name); + var directory = Path.GetDirectoryName(name); + + var count = Directory + .GetFiles(directory!, $"{fileName}*") + .Select(RetrieveImageIdentifier) + .Max(); + + return directory + + fileName + + Paths.ImageIdSeparator + + (++count).ToString() + + extension; + } + + private static int? RetrieveImageIdentifier(this string path) + { + string name = Path.GetFileNameWithoutExtension(path); + string[] nameParts = name.Split(Paths.ImageIdSeparator); + + if (nameParts.Length < 2) + { + return 0; + } + + if (!int.TryParse(nameParts.Last(), out int id)) + { + return null; + } + + return id; + } + } +} diff --git a/DAL/ImagePersistence/Utils/ImageNameFormatException.cs b/DAL/ImagePersistence/Utils/ImageNameFormatException.cs new file mode 100644 index 0000000000000000000000000000000000000000..9449a6f52ac0798a691f0c043f7dc6e52aded64f --- /dev/null +++ b/DAL/ImagePersistence/Utils/ImageNameFormatException.cs @@ -0,0 +1,17 @@ +namespace DAL.ImagePersistence.Utils +{ + public class ImageNameFormatException : Exception + { + public ImageNameFormatException() + { + } + + public ImageNameFormatException(string? message) : base(message) + { + } + + public ImageNameFormatException(string? message, Exception? innerException) : base(message, innerException) + { + } + } +} diff --git a/pv179-project.sln b/pv179-project.sln index fb9b03f362eee76411b522aa59b361f000a1b371..84f78eaad1756d42d086e7644bc421ef30f1f7bb 100644 --- a/pv179-project.sln +++ b/pv179-project.sln @@ -13,7 +13,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MVC", "MVC\MVC.csproj", "{1 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BusinessLayer.Tests", "BusinessLayer.Tests\BusinessLayer.Tests.csproj", "{B244DC7E-77CF-4B9A-B382-BC8E1E4B057C}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestUtilities", "TestUtilities\TestUtilities.csproj", "{7F02D143-33B1-4A83-BE5E-8AE192DA6A8D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestUtilities", "TestUtilities\TestUtilities.csproj", "{DBAC81BF-6294-43DA-AA77-A293C8D03EDF}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -41,10 +41,10 @@ Global {B244DC7E-77CF-4B9A-B382-BC8E1E4B057C}.Debug|Any CPU.Build.0 = Debug|Any CPU {B244DC7E-77CF-4B9A-B382-BC8E1E4B057C}.Release|Any CPU.ActiveCfg = Release|Any CPU {B244DC7E-77CF-4B9A-B382-BC8E1E4B057C}.Release|Any CPU.Build.0 = Release|Any CPU - {7F02D143-33B1-4A83-BE5E-8AE192DA6A8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7F02D143-33B1-4A83-BE5E-8AE192DA6A8D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7F02D143-33B1-4A83-BE5E-8AE192DA6A8D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7F02D143-33B1-4A83-BE5E-8AE192DA6A8D}.Release|Any CPU.Build.0 = Release|Any CPU + {DBAC81BF-6294-43DA-AA77-A293C8D03EDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBAC81BF-6294-43DA-AA77-A293C8D03EDF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBAC81BF-6294-43DA-AA77-A293C8D03EDF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBAC81BF-6294-43DA-AA77-A293C8D03EDF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE