diff --git a/zero.Core/Collections/CollectionBase.cs b/zero.Core/Collections/CollectionBase.cs index d11d9512..f3b56e78 100644 --- a/zero.Core/Collections/CollectionBase.cs +++ b/zero.Core/Collections/CollectionBase.cs @@ -24,6 +24,8 @@ namespace zero.Core.Collections protected ICollectionInterceptorHandler InterceptorHandler { get; private set; } + protected virtual Action PreSave { get; set; } + public CollectionBase(IZeroContext context, ICollectionInterceptorHandler interceptorHandler, IValidator validator = null) { @@ -171,6 +173,13 @@ namespace zero.Core.Collections /// public virtual async Task> Save(T model) { + if (model == null) + { + return EntityResult.Fail("@errors.onsave.empty"); + } + + PreSave?.Invoke(model); + bool isCreate = false; // find all Raven Ids diff --git a/zero.Core/Collections/Media/MediaCollection.cs b/zero.Core/Collections/Media/MediaCollection.cs new file mode 100644 index 00000000..ffa11de0 --- /dev/null +++ b/zero.Core/Collections/Media/MediaCollection.cs @@ -0,0 +1,257 @@ +using FluentValidation; +using Microsoft.AspNetCore.Http; +using Raven.Client.Documents; +using Raven.Client.Documents.Linq; +using Raven.Client.Documents.Session; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using zero.Core.Api; +using zero.Core.Database.Indexes; +using zero.Core.Entities; +using zero.Core.Extensions; + +namespace zero.Core.Collections +{ + public class MediaCollection : CollectionBase, IMediaCollection + { + public MediaCollection(IZeroContext context, ICollectionInterceptorHandler interceptor, IValidator validator, IPaths paths) : base(context, interceptor, validator) + { + PreSave = model => model.IsActive = true; + Paths = paths; + } + + + protected const char PATH_SEPARATOR = '/'; + + protected const string PATH_PREFIX = "/uploads"; + + protected const string THUMB_EXTENSION = ".thumb"; + + protected const string PREVIEW_EXTENSION = ".preview"; + + protected string[] ImageExtensions = new[] { ".jpg", ".jpeg", ".png", ".bmp", ".webp", ".jfif", ".gif" }; + + protected IPaths Paths { get; set; } + + + /// + public async Task GetSourceById(string id, bool thumb = false) + { + IMedia media = await Session.LoadAsync(id); + + if (media == null) + { + return null; + } + + if (media.Source.StartsWith("url://")) + { + return media.Source.Substring(6) + "?preset=productMini"; // TODO remove this + } + + return thumb ? (media.ThumbnailSource ?? media.Source) : media.Source; + } + + + /// + public async Task> GetByQuery(MediaListQuery query) + { + query.SearchFor(entity => entity.Name); + + return await Query + .WhereIf(x => x.FolderId == query.FolderId, !query.FolderId.IsNullOrEmpty(), x => x.FolderId == null) + .ToQueriedListAsync(query); + } + + + /// + public async Task> Move(string id, string parentId) + { + IMedia model = await GetById(id); + IMediaFolder parent = await Session.LoadAsync(parentId); + + if (model == null || (!parentId.IsNullOrEmpty() && parent == null)) + { + return EntityResult.Fail("@errors.idnotfound"); + } + + model.FolderId = parent?.Id; + + return await Save(model); + } + + + /// + public async Task Upload(IFormFile file, string folderId, CancellationToken cancellationToken = default) + { + Media media = new Media(); + + // generate file id which is used as the folder name on disk + media.FileId = Guid.NewGuid().ToString(); + media.FolderId = folderId; + + // generate file name + media.Name = Safenames.File(file.FileName); + + // build folder and full file path + string folderPath = Path.Combine(Paths.Media, media.FileId); + string filePath = Path.Combine(folderPath, media.Name); + string extension = Path.GetExtension(filePath); + + // find media type + media.Type = ImageExtensions.Contains(extension, StringComparer.InvariantCultureIgnoreCase) ? MediaType.Image : MediaType.File; + + Paths.Create(folderPath); + + // write media file to disk + using (var stream = File.Create(filePath)) + { + await file.CopyToAsync(stream, cancellationToken); + } + + // set new properties + media.Source = Path.Combine(PATH_PREFIX, media.FileId, media.Name).Replace(Path.DirectorySeparatorChar, PATH_SEPARATOR); + media.Size = file.Length; + + // write additional image data + thumbnail + if (media.Type == MediaType.Image) + { + using (Image image = Image.Load(filePath)) + { + media.ImageMeta = GetImageMeta(image); + + Image imageFrame = media.ImageMeta.Frames > 1 ? image.Frames.CloneFrame(0) : image; + + media.PreviewSource = SaveThumbnail(media, imageFrame, PREVIEW_EXTENSION, new ResizeOptions() + { + Size = new Size(210, 210), + Mode = ResizeMode.Min + }); + + media.ThumbnailSource = SaveThumbnail(media, imageFrame, THUMB_EXTENSION, new ResizeOptions() + { + Size = new Size(100, 100), + Mode = ResizeMode.Max + }); + } + } + + return media; + } + + + /// + public async Task> GetListByQuery(MediaListItemQuery query) + { + bool hasSearch = !query.Search.IsNullOrWhiteSpace(); + bool isRoot = query.FolderId.IsNullOrWhiteSpace(); + + query.SearchFor(entity => entity.Name); + + query.OrderQuery = q => q + .OrderByDescending(x => x.IsFolder) + .ThenBy(query.OrderBy, query.OrderIsDescending, query.OrderType == ListQueryOrderType.String ? OrderingType.String : OrderingType.Double); + + Session.Advanced.MaxNumberOfRequestsPerSession = query.PageSize + 1; + + IRavenQueryable dbQuery = Session.Query().ProjectInto(); + + if (!hasSearch || !query.SearchIsGlobal) + { + dbQuery = dbQuery.WhereIf(x => x.ParentId == query.FolderId, !query.FolderId.IsNullOrEmpty(), x => x.ParentId == null); + } + + ListResult result = await dbQuery.ToQueriedListAsync(query); + + foreach (MediaListItem item in result.Items) + { + if (item.IsFolder) + { + item.Children = await Session.Query().CountAsync(x => x.ParentId == item.Id); + } + } + + // TODO this is only inserted when we have a core/shared database and a multi-tenancy setup + if (isRoot) + { + result.Items.Insert(0, new MediaListItem() + { + Id = "shared", + IsFolder = true + }); + } + + return result; + } + + + /// + /// Saves a thumbnail of an image + /// + string SaveThumbnail(IMedia media, Image image, string extensionPrefix, ResizeOptions resizeOptions) + { + string extension = Path.GetExtension(media.Source); + + image.Mutate(x => x.Resize(resizeOptions)); + + string thumbFileName = media.Name.TrimEnd(extension) + extensionPrefix + extension; + image.Save(Path.Combine(Paths.Media, media.FileId, thumbFileName)); + return Path.Combine(PATH_PREFIX, media.FileId, thumbFileName).Replace(Path.DirectorySeparatorChar, PATH_SEPARATOR); + } + + + /// + /// Create image data if available + /// + MediaImageMeta GetImageMeta(Image image) + { + var pngMetadata = image.Metadata.GetPngMetadata(); + + return new MediaImageMeta() + { + Width = image.Width, + Height = image.Height, + CreatedDate = new DateTimeOffset(image.Metadata.IccProfile?.Header?.CreationDate ?? DateTime.Now), + DPI = image.Metadata.HorizontalResolution, + ColorSpace = image.Metadata.IccProfile?.Header?.DataColorSpace.ToString(), + HasTransparency = pngMetadata?.HasTransparency ?? false, + Frames = image.Frames.Count + }; + } + } + + + public interface IMediaCollection : ICollectionBase + { + /// + /// Get media source by Id + /// + Task GetSourceById(string id, bool thumb = false); + + /// + /// Get all available media items with query + /// + Task> GetByQuery(MediaListQuery query); + + /// + /// Get all available media items (including folders) with query + /// + Task> GetListByQuery(MediaListItemQuery query); + + /// + /// Move a file to a new parent + /// + Task> Move(string id, string parentId); + + /// + /// Uploads a file to the media folder + /// + Task Upload(IFormFile file, string folderId, CancellationToken cancellationToken = default); + } +} diff --git a/zero.Core/Validation/MediaFolderValidator.cs b/zero.Core/Collections/Media/MediaFolderValidator.cs similarity index 86% rename from zero.Core/Validation/MediaFolderValidator.cs rename to zero.Core/Collections/Media/MediaFolderValidator.cs index f7ca42c6..1b298802 100644 --- a/zero.Core/Validation/MediaFolderValidator.cs +++ b/zero.Core/Collections/Media/MediaFolderValidator.cs @@ -2,8 +2,9 @@ using zero.Core.Api; using zero.Core.Entities; using zero.Core.Extensions; +using zero.Core.Validation; -namespace zero.Core.Validation +namespace zero.Core.Collections { public class MediaFolderValidator : ZeroValidator { diff --git a/zero.Core/Validation/MediaValidator.cs b/zero.Core/Collections/Media/MediaValidator.cs similarity index 67% rename from zero.Core/Validation/MediaValidator.cs rename to zero.Core/Collections/Media/MediaValidator.cs index c7027fdd..6df5c4de 100644 --- a/zero.Core/Validation/MediaValidator.cs +++ b/zero.Core/Collections/Media/MediaValidator.cs @@ -1,15 +1,15 @@ using FluentValidation; -using zero.Core.Api; using zero.Core.Entities; +using zero.Core.Validation; -namespace zero.Core.Validation +namespace zero.Core.Collections { public class MediaValidator : ZeroValidator { - public MediaValidator(IBackofficeStore store) + public MediaValidator() { RuleFor(x => x.Name).Length(2, 80); RuleFor(x => x.IsActive).Equal(true); } } -} \ No newline at end of file +} diff --git a/zero.Web/Controllers/BackofficeCollectionController.cs b/zero.Web/Controllers/BackofficeCollectionController.cs index 4c6e97e2..9b7c7cfc 100644 --- a/zero.Web/Controllers/BackofficeCollectionController.cs +++ b/zero.Web/Controllers/BackofficeCollectionController.cs @@ -36,6 +36,9 @@ namespace zero.Web.Controllers public virtual async Task> GetById([FromQuery] string id) => Edit(await Collection.GetById(id)); + public virtual async Task> GetByIds([FromQuery] string[] ids) => await Collection.GetByIds(ids); + + public virtual EditModel GetEmpty([FromServices] TEntity blueprint) => Edit(blueprint); @@ -58,9 +61,11 @@ namespace zero.Web.Controllers public virtual async Task> GetPreviews([FromQuery] List ids) => Previews(await Collection.GetByIds(ids.ToArray()), PreviewTransform); + [HttpPost] public virtual async Task> Save([FromBody] TEntity model) => await Collection.Save(model); + [HttpDelete] public virtual async Task> Delete([FromQuery] string id) => await Collection.DeleteById(id); } } diff --git a/zero.Web/Controllers/MediaController.cs b/zero.Web/Controllers/MediaController.cs index d6fe7c29..aaf3311a 100644 --- a/zero.Web/Controllers/MediaController.cs +++ b/zero.Web/Controllers/MediaController.cs @@ -9,6 +9,7 @@ using System.Net; using System.Threading.Tasks; using zero.Core; using zero.Core.Api; +using zero.Core.Collections; using zero.Core.Entities; using zero.Core.Identity; using zero.Web.Models; @@ -16,48 +17,35 @@ using zero.Web.Models; namespace zero.Web.Controllers { [ZeroAuthorize(Permissions.Sections.Media, PermissionsValue.True)] - public class MediaController : BackofficeController + public class MediaController : BackofficeCollectionController { - IMediaApi Api; IMediaFolderApi MediaFolderApi; IPaths Paths; - - public MediaController(IMediaApi api, IMediaFolderApi mediaFolderApi, IPaths paths) + public MediaController(IMediaCollection collection, IMediaFolderApi mediaFolderApi, IPaths paths) : base(collection) { - Api = api; MediaFolderApi = mediaFolderApi; Paths = paths; + PreviewTransform = (item, model) => model.Icon = "fth-globe"; } - - public async Task> GetById([FromQuery] string id) => Edit(await Api.GetById(id)); + public async Task> GetListByQuery([FromQuery] MediaListItemQuery query) => await Collection.GetListByQuery(query); - public async Task> GetByIds([FromQuery] string[] ids) => await Api.GetById(ids); - - - public async Task> GetListByQuery([FromQuery] MediaListItemQuery query) => await Api.GetListByQuery(query); - - - public async Task> Save([FromBody] IMedia model) => await Api.Save(model); [HttpPost] - public async Task> Upload(IFormFile file, string folderId) => await Api.Save(await Api.Upload(file, folderId)); + public async Task> Upload(IFormFile file, string folderId) => await Collection.Save(await Collection.Upload(file, folderId)); [HttpPost] - public async Task UploadTemporary(IFormFile file, string folderId) => await Api.Upload(file, folderId); - - [HttpDelete] - public async Task> Delete([FromQuery] string id) => await Api.Delete(id); + public async Task UploadTemporary(IFormFile file, string folderId) => await Collection.Upload(file, folderId); [HttpPost] - public async Task> Move([FromBody] ActionCopyModel model) => await Api.Move(model.Id, model.DestinationId); + public async Task> Move([FromBody] ActionCopyModel model) => await Collection.Move(model.Id, model.DestinationId); public async Task GetAll([FromQuery] MediaListQuery query) { - ListResult items = (await Api.GetByQuery(query)).MapTo(x => new MediaListModel() + ListResult items = (await Collection.GetByQuery(query)).MapTo(x => new MediaListModel() { Id = x.Id, IsFolder = false, @@ -81,9 +69,9 @@ namespace zero.Web.Controllers [HttpGet] - public async Task StreamThumbnail([FromQuery] string id, [FromQuery] bool thumb = true, [FromQuery] bool core = false) + public async Task StreamThumbnail([FromQuery] string id, [FromQuery] bool thumb = true) { - string path = await Api.GetSourceById(id, thumb, core); + string path = await Collection.GetSourceById(id, thumb); if (path == null) { diff --git a/zero.Web/Defaults/ZeroBackofficePlugin.cs b/zero.Web/Defaults/ZeroBackofficePlugin.cs index 6d525347..34b3bdae 100644 --- a/zero.Web/Defaults/ZeroBackofficePlugin.cs +++ b/zero.Web/Defaults/ZeroBackofficePlugin.cs @@ -74,6 +74,7 @@ namespace zero.Web.Defaults services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/zero.Web/Resources/Localization/zero.en-us.json b/zero.Web/Resources/Localization/zero.en-us.json index 32c9fff0..f80a0236 100644 --- a/zero.Web/Resources/Localization/zero.en-us.json +++ b/zero.Web/Resources/Localization/zero.en-us.json @@ -132,7 +132,8 @@ "errors": { "idnotfound": "Could not find an entity for the given ID", "onsave": { - "notallowed": "You are not allowed to edit this resource" + "notallowed": "You are not allowed to edit this resource", + "empty": "You need to pass a model" }, "ondelete": { "idnotfound": "Could not find an entity for the given ID"