diff --git a/zero.Core/Api/ApiBase.cs b/zero.Core/Api/ApiBase.cs index 91816a93..4fcc2edc 100644 --- a/zero.Core/Api/ApiBase.cs +++ b/zero.Core/Api/ApiBase.cs @@ -122,7 +122,7 @@ namespace zero.Core.Api } // update name alias - model.Alias = Alias.Generate(model.Name); + model.Alias = Safenames.Alias(model.Name); using (IAsyncDocumentSession session = Raven.OpenAsyncSession()) { diff --git a/zero.Core/Api/BackofficeApi.cs b/zero.Core/Api/BackofficeApi.cs index d644e727..cc782638 100644 --- a/zero.Core/Api/BackofficeApi.cs +++ b/zero.Core/Api/BackofficeApi.cs @@ -164,7 +164,7 @@ namespace zero.Core.Api // update name alias if (zeroEntity != null) { - zeroEntity.Alias = Alias.Generate(zeroEntity.Name); + zeroEntity.Alias = Safenames.Alias(zeroEntity.Name); } using (IAsyncDocumentSession session = Raven.OpenAsyncSession()) diff --git a/zero.Core/Api/MediaApi.cs b/zero.Core/Api/MediaApi.cs index 1a6bfff2..b0c7d96b 100644 --- a/zero.Core/Api/MediaApi.cs +++ b/zero.Core/Api/MediaApi.cs @@ -35,7 +35,8 @@ namespace zero.Core.Api /// public async Task> Save(Media model) { - return await Save(model); + model.IsActive = true; + return await SaveModel(model); } diff --git a/zero.Core/Api/MediaUploadApi.cs b/zero.Core/Api/MediaUploadApi.cs new file mode 100644 index 00000000..3aa47981 --- /dev/null +++ b/zero.Core/Api/MediaUploadApi.cs @@ -0,0 +1,118 @@ +using Microsoft.AspNetCore.Http; +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.Entities; +using zero.Core.Extensions; + +namespace zero.Core.Api +{ + public class MediaUploadApi : IMediaUploadApi + { + protected IPaths Paths { get; set; } + + private const char PATH_SEPARATOR = '/'; + + private const string PATH_PREFIX = "/uploads"; + + private const char SEP = ','; + + private const string THUMB_EXTENSION = ".thumb"; + + private const string PREVIEW_EXTENSION = ".preview"; + + private const string UPLOAD_PREFIX = "upload:"; + + private string[] ImageExtensions = new[] { ".jpg", ".jpeg", ".png", ".bmp", ".webp", ".jfif" }; + + + public MediaUploadApi(IPaths paths) + { + Paths = paths; + } + + + /// + 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); + } + + // write additional image data + thumbnail + if (media.Type == MediaType.Image) + { + using (Image image = Image.Load(filePath)) + { + media.Dimension = new MediaDimension() + { + Width = image.Width, + Height = image.Height + }; + + image.Mutate(x => x.Resize(new ResizeOptions() + { + Size = new Size(210, 210), + Mode = ResizeMode.Min + })); + + string thumbFileName = media.Name.TrimEnd(extension) + PREVIEW_EXTENSION + extension; + image.Save(Path.Combine(Paths.Media, media.FileId, thumbFileName)); + media.PreviewSource = Path.Combine(PATH_PREFIX, media.FileId, thumbFileName).Replace(Path.DirectorySeparatorChar, PATH_SEPARATOR); + + image.Mutate(x => x.Resize(new ResizeOptions() + { + Size = new Size(100, 100), + Mode = ResizeMode.Max + })); + + thumbFileName = media.Name.TrimEnd(extension) + THUMB_EXTENSION + extension; + image.Save(Path.Combine(Paths.Media, media.FileId, thumbFileName)); + media.ThumbnailSource = Path.Combine(PATH_PREFIX, media.FileId, thumbFileName).Replace(Path.DirectorySeparatorChar, PATH_SEPARATOR); + } + } + + // set new properties + media.LastModifiedDate = DateTimeOffset.Now; + media.Source = Path.Combine(PATH_PREFIX, media.FileId, media.Name).Replace(Path.DirectorySeparatorChar, PATH_SEPARATOR); + media.Size = file.Length; + + return media; + } + } + + + public interface IMediaUploadApi + { + /// + /// Uploads a file to the media folder + /// + Task Upload(IFormFile file, string folderId, CancellationToken cancellationToken = default); + } +} diff --git a/zero.Core/Api/Alias.cs b/zero.Core/Api/Safenames.cs similarity index 76% rename from zero.Core/Api/Alias.cs rename to zero.Core/Api/Safenames.cs index 98370e33..b4ab06cd 100644 --- a/zero.Core/Api/Alias.cs +++ b/zero.Core/Api/Safenames.cs @@ -1,22 +1,49 @@ using System; +using System.IO; using System.Text; using zero.Core.Extensions; namespace zero.Core.Api { - public class Alias + public class Safenames { - private const char HYPHEN = '-'; + public enum Scope + { + Url, + File + } - private const char PLUS = '+'; + const char HYPHEN = '-'; - private const char AMPERSAND = '&'; + const char DOT = '.'; + + const char PLUS = '+'; + + const char AMPERSAND = '&'; + + + /// + /// Converts an untrusted to a safe filename + /// + public static string File(string value) + { + return Generate(Path.GetFileName(value), Scope.File); + } /// /// Converts a term to a safe alias (suitable for URLs) /// - public static string Generate(string value) + public static string Alias(string value) + { + return Generate(value, Scope.Url); + } + + + /// + /// + /// + static string Generate(string value, Scope scope) { if (String.IsNullOrWhiteSpace(value)) { @@ -58,6 +85,10 @@ namespace zero.Core.Api { target = PLUS; } + else if (scope == Scope.File && character == DOT) + { + target = DOT; + } // add hyphen for all other characters else { diff --git a/zero.Core/Api/SetupApi.cs b/zero.Core/Api/SetupApi.cs index 0f9ec01d..08ea604d 100644 --- a/zero.Core/Api/SetupApi.cs +++ b/zero.Core/Api/SetupApi.cs @@ -64,7 +64,7 @@ namespace zero.Core.Api CreatedDate = DateTimeOffset.Now, IsActive = true, Name = model.AppName, - Alias = Alias.Generate(model.AppName) + Alias = Safenames.Alias(model.AppName) }; // create user @@ -76,7 +76,7 @@ namespace zero.Core.Api Name = model.User.Name, IsActive = true, LanguageId = Options.DefaultLanguage, - Alias = Alias.Generate(model.User.Name), + Alias = Safenames.Alias(model.User.Name), IsEmailConfirmed = true }; @@ -84,7 +84,7 @@ namespace zero.Core.Api Language language = new Language() // TODO get default language selection from setup UI { Name = "English", - Alias = Alias.Generate("English"), + Alias = Safenames.Alias("English"), CreatedDate = DateTimeOffset.Now, Code = "en-US", IsActive = true, @@ -209,7 +209,7 @@ namespace zero.Core.Api { CreatedDate = DateTimeOffset.Now, IsActive = true, - Alias = Alias.Generate(country.Value), + Alias = Safenames.Alias(country.Value), LanguageId = defaultLanguage.Id, Code = country.Key.ToLowerInvariant(), Name = country.Value @@ -230,7 +230,7 @@ namespace zero.Core.Api UserRole adminRole = new UserRole() { Name = "Administrator", - Alias = Alias.Generate("Administrator"), + Alias = Safenames.Alias("Administrator"), Sort = 0, AppId = Constants.Database.SharedAppId, Icon = "fth-award", @@ -256,7 +256,7 @@ namespace zero.Core.Api UserRole editorRole = new UserRole() { Name = "Editor", - Alias = Alias.Generate("Editor"), + Alias = Safenames.Alias("Editor"), Sort = 1, AppId = Constants.Database.SharedAppId, Icon = "fth-feather", @@ -276,7 +276,7 @@ namespace zero.Core.Api UserRole defaultRole = new UserRole() { Name = "Standard", - Alias = Alias.Generate("Standard"), + Alias = Safenames.Alias("Standard"), Sort = 2, AppId = Constants.Database.SharedAppId, Icon = "fth-users", diff --git a/zero.Core/Api/SpacesApi.cs b/zero.Core/Api/SpacesApi.cs index dd47bec7..e11065d8 100644 --- a/zero.Core/Api/SpacesApi.cs +++ b/zero.Core/Api/SpacesApi.cs @@ -144,7 +144,7 @@ namespace zero.Core.Api } model.SpaceAlias = alias; - model.Alias = Alias.Generate(model.Name); + model.Alias = Safenames.Alias(model.Name); using (IAsyncDocumentSession session = Raven.OpenAsyncSession()) { diff --git a/zero.Core/Api/UserRolesApi.cs b/zero.Core/Api/UserRolesApi.cs index d524efbb..52915831 100644 --- a/zero.Core/Api/UserRolesApi.cs +++ b/zero.Core/Api/UserRolesApi.cs @@ -67,7 +67,7 @@ namespace zero.Core.Api model.CreatedDate = DateTimeOffset.Now; } - model.Alias = Alias.Generate(model.Name); + model.Alias = Safenames.Alias(model.Name); using (IAsyncDocumentSession session = Raven.OpenAsyncSession()) { diff --git a/zero.Core/Entities/Media/Media.cs b/zero.Core/Entities/Media/Media.cs index af845b57..77a57de1 100644 --- a/zero.Core/Entities/Media/Media.cs +++ b/zero.Core/Entities/Media/Media.cs @@ -1,4 +1,5 @@ using System; +using zero.Core.Attributes; namespace zero.Core.Entities { @@ -29,7 +30,10 @@ namespace zero.Core.Entities public string ThumbnailSource { get; set; } /// - public int Size { get; set; } + public string PreviewSource { get; set; } + + /// + public long Size { get; set; } /// public MediaDimension Dimension { get; set; } @@ -39,13 +43,17 @@ namespace zero.Core.Entities /// public MediaFocalPoint FocalPoint { get; set; } + + /// + public MediaType Type { get; set; } } + [Collection("Media")] public interface IMedia : IZeroEntity, IAppAwareEntity, IZeroDbConventions { /// - /// Id/name of the folder which is stored on disk/cloud + /// Id/name of the phyiscal folder which is stored on disk/cloud /// public string FileId { get; set; } @@ -74,10 +82,15 @@ namespace zero.Core.Entities /// string ThumbnailSource { get; set; } + /// + /// For images this is the source for a [proportional]x210px thumbnail + /// + string PreviewSource { get; set; } + /// /// Filesize in bytes /// - int Size { get; set; } + long Size { get; set; } /// /// Dimension (width + height) in pixels @@ -93,5 +106,10 @@ namespace zero.Core.Entities /// Optional focal point for an image /// MediaFocalPoint FocalPoint { get; set; } + + /// + /// Type of the media + /// + MediaType Type { get; set; } } } diff --git a/zero.Core/Entities/Media/MediaType.cs b/zero.Core/Entities/Media/MediaType.cs new file mode 100644 index 00000000..b33ff27b --- /dev/null +++ b/zero.Core/Entities/Media/MediaType.cs @@ -0,0 +1,8 @@ +namespace zero.Core.Entities +{ + public enum MediaType + { + File = 0, + Image = 1 + } +} diff --git a/zero.Core/Identity/RoleStore.cs b/zero.Core/Identity/RoleStore.cs index 11c1df16..2a08a4fc 100644 --- a/zero.Core/Identity/RoleStore.cs +++ b/zero.Core/Identity/RoleStore.cs @@ -93,7 +93,7 @@ namespace zero.Core.Identity /// public Task SetRoleNameAsync(TRole role, string roleName, CancellationToken cancellationToken) { - role.Alias = Alias.Generate(roleName); + role.Alias = Safenames.Alias(roleName); return Task.CompletedTask; } diff --git a/zero.Web.UI/App/pages/media/media.vue b/zero.Web.UI/App/pages/media/media.vue index e04b67dd..cf56e18b 100644 --- a/zero.Web.UI/App/pages/media/media.vue +++ b/zero.Web.UI/App/pages/media/media.vue @@ -34,8 +34,8 @@ - - {{item.source}} + + {{item.name}} @@ -265,14 +265,18 @@ name: file.name, size: file.size, mimeType: file.type, - source: null, - file: file + source: null }); + //items.push(this.addFile(files[i])); + + MediaApi.upload(file, this.id).then(res => + { + console.info(res); + }); } } - console.info(items); //this.update(); }, diff --git a/zero.Web.UI/App/resources/media.js b/zero.Web.UI/App/resources/media.js index 2d485031..028148c7 100644 --- a/zero.Web.UI/App/resources/media.js +++ b/zero.Web.UI/App/resources/media.js @@ -28,6 +28,27 @@ export default { return Axios.post(base + 'save', model).then(res => Promise.resolve(res.data)); }, + // uploads a file + upload(file, folderId) + { + var data = new FormData(); + data.append('file', file); + data.append('folderId', folderId); + + return Axios.post(base + 'upload', data, { + onUploadProgress: function (progressEvent) + { + var percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); + console.info('upload: ' + percentCompleted); + } + }).then(function (res) + { + Promise.resolve(res.data); + }); + + //return Axios.post(base + 'save', model).then(res => Promise.resolve(res.data)); + }, + // deletes a media delete(id) { diff --git a/zero.Web/Controllers/MediaController.cs b/zero.Web/Controllers/MediaController.cs index cbcce8b1..bec65807 100644 --- a/zero.Web/Controllers/MediaController.cs +++ b/zero.Web/Controllers/MediaController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; using System.Linq; @@ -18,11 +19,14 @@ namespace zero.Web.Controllers IMediaFolderApi MediaFolderApi; + IMediaUploadApi MediaUploadApi; - public MediaController(IMediaApi api, IMediaFolderApi mediaFolderApi) + + public MediaController(IMediaApi api, IMediaFolderApi mediaFolderApi, IMediaUploadApi mediaUploadApi) { Api = api; MediaFolderApi = mediaFolderApi; + MediaUploadApi = mediaUploadApi; } @@ -68,6 +72,16 @@ namespace zero.Web.Controllers } + /// + /// Upload a file + /// + public async Task Upload(IFormFile file, string folderId) + { + Media media = await MediaUploadApi.Upload(file, folderId); + return Json(await Api.Save(media)); + } + + /// /// Deletes a media item /// diff --git a/zero.Web/Controllers/SettingsController.cs b/zero.Web/Controllers/SettingsController.cs index 1b2e9c1b..59d4bbaa 100644 --- a/zero.Web/Controllers/SettingsController.cs +++ b/zero.Web/Controllers/SettingsController.cs @@ -52,7 +52,7 @@ namespace zero.Web.Controllers Name = area.Name, Description = area.Description, Icon = area.Icon, - Url = Constants.Sections.Settings.EnsureStartsWith('/') + Alias.Generate(area.Alias).EnsureStartsWith('/') + Url = Constants.Sections.Settings.EnsureStartsWith('/') + Safenames.Alias(area.Alias).EnsureStartsWith('/') }; areas.Add(vueArea); diff --git a/zero.Web/Defaults/ZeroBackofficePlugin.cs b/zero.Web/Defaults/ZeroBackofficePlugin.cs index 11e40162..041ad37c 100644 --- a/zero.Web/Defaults/ZeroBackofficePlugin.cs +++ b/zero.Web/Defaults/ZeroBackofficePlugin.cs @@ -48,6 +48,7 @@ namespace zero.Web.Defaults services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); } diff --git a/zero.Web/Mapper/MediaMapperConfig.cs b/zero.Web/Mapper/MediaMapperConfig.cs index 53d52ea5..ee83104e 100644 --- a/zero.Web/Mapper/MediaMapperConfig.cs +++ b/zero.Web/Mapper/MediaMapperConfig.cs @@ -38,11 +38,11 @@ namespace zero.Web.Mapper config.CreateMap((source, target) => { target.Id = source.Id; - target.Name = source.Name; target.IsFolder = false; + target.Name = source.Name; + target.Type = source.Type; target.Size = source.Size; - target.Source = source.Source; - target.ThumbnailSource = source.ThumbnailSource; + target.Source = source.PreviewSource ?? source.Source; }); config.CreateMap((source, target) => diff --git a/zero.Web/Models/MediaListModel.cs b/zero.Web/Models/MediaListModel.cs index 7058e2ce..22f94791 100644 --- a/zero.Web/Models/MediaListModel.cs +++ b/zero.Web/Models/MediaListModel.cs @@ -1,15 +1,19 @@ -namespace zero.Web.Models +using zero.Core.Entities; + +namespace zero.Web.Models { public class MediaListModel : ListModel { public bool IsFolder { get; set; } + public MediaType Type { get; set; } + public string Name { get; set; } public string Source { get; set; } public string ThumbnailSource { get; set; } - public int Size { get; set; } + public long Size { get; set; } } } diff --git a/zero.Web/ZeroVue.cs b/zero.Web/ZeroVue.cs index 23d1eb29..c627ec75 100644 --- a/zero.Web/ZeroVue.cs +++ b/zero.Web/ZeroVue.cs @@ -97,7 +97,7 @@ namespace zero.Web } bool isExternal = !(section is IZeroInternal); - string url = Alias.Generate(section.Alias).EnsureStartsWith('/'); + string url = Safenames.Alias(section.Alias).EnsureStartsWith('/'); if (section.Alias == Constants.Sections.Dashboard) { @@ -121,7 +121,7 @@ namespace zero.Web { Alias = child.Alias, Name = child.Name, - Url = vueSection.Url.EnsureEndsWith('/') + Alias.Generate(child.Alias), + Url = vueSection.Url.EnsureEndsWith('/') + Safenames.Alias(child.Alias), IsExternal = isExternal }); } @@ -192,7 +192,7 @@ namespace zero.Web Name = area.Name, Description = area.Description, Icon = area.Icon, - Url = Constants.Sections.Settings.EnsureStartsWith('/') + Alias.Generate(area.Alias).EnsureStartsWith('/'), + Url = Constants.Sections.Settings.EnsureStartsWith('/') + Safenames.Alias(area.Alias).EnsureStartsWith('/'), IsPlugin = isPlugin };