From 313bd59b98238394f286b25f606fb4649432adff Mon Sep 17 00:00:00 2001 From: Tobias Klika Date: Sat, 10 Dec 2022 20:17:22 +0100 Subject: [PATCH] add media module + imagesharp integration --- .../RavenIdentityStoreDbProvider.cs | 37 ++++ .../DbProviders/RavenMediaStoreDbProvider.cs | 37 ++++ zero.Raven/Operations/RavenOperations.cs | 7 +- zero.Raven/ZeroRavenModule.cs | 8 +- zero.Raven/zero.Raven.csproj | 4 + zero/Identity/IZeroIdentityStoreDbProvider.cs | 16 ++ .../Identity/IdentityBuilderExtensions.cs | 13 +- zero/Identity/Models/ZeroIdentityUser.cs | 3 + .../Identity/ServiceCollectionExtensions.cs | 25 ++- .../Identity/ZeroRoleStore(TRole).cs | 21 +- .../Identity/ZeroUserStore(TUser).cs | 35 ++- .../Identity/ZeroUserStore(TUser,TRole).cs | 9 +- zero/Media/IZeroMediaStoreDbProvider.cs | 16 ++ zero/Media/ImageSharp/PhysicalFileProvider.cs | 84 +++++++ zero/Media/ImageSharp/PresetRequestParser.cs | 143 ++++++++++++ zero/Media/ImageSharp/RemoteImageCache.cs | 105 +++++++++ zero/Media/ImageSharp/ResizeTagHelper.cs | 34 +++ zero/Media/Indexes/Media_ByChildren.cs | 38 ++++ zero/Media/Indexes/Media_ByHierarchy.cs | 45 ++++ zero/Media/MediaCreator.cs | 147 +++++++++++++ zero/Media/MediaExtensions.cs | 72 ++++++ zero/Media/MediaFileSystem.cs | 39 ++++ zero/Media/MediaManagement.cs | 205 ++++++++++++++++++ zero/Media/MediaManagementExtensions.cs | 120 ++++++++++ zero/Media/MediaMetadataCache.cs | 60 +++++ zero/Media/MediaOptions.cs | 43 ++++ zero/Media/Models/CachedMedia.cs | 70 ++++++ zero/Media/Models/Media.cs | 53 +++++ zero/Media/Models/MediaFocalPoint.cs | 13 ++ zero/Media/Models/MediaListItem.cs | 24 ++ zero/Media/Models/MediaMetadata.cs | 27 +++ zero/Media/Models/RemoteMedia.cs | 8 + zero/Media/Models/Video.cs | 24 ++ zero/Media/ZeroMediaModule.cs | 56 +++++ zero/Models/IAlwaysActive.cs | 12 - zero/Usings.cs | 3 +- zero/ZeroBuilder.cs | 2 +- zero/zero.csproj | 1 + 38 files changed, 1587 insertions(+), 72 deletions(-) create mode 100644 zero.Raven/DbProviders/RavenIdentityStoreDbProvider.cs create mode 100644 zero.Raven/DbProviders/RavenMediaStoreDbProvider.cs create mode 100644 zero/Identity/IZeroIdentityStoreDbProvider.cs rename {zero.Raven => zero}/Identity/IdentityBuilderExtensions.cs (50%) rename {zero.Raven => zero}/Identity/ServiceCollectionExtensions.cs (81%) rename zero.Raven/Identity/RavenRoleStore(TRole).cs => zero/Identity/ZeroRoleStore(TRole).cs (86%) rename zero.Raven/Identity/RavenUserStore(TUser).cs => zero/Identity/ZeroUserStore(TUser).cs (91%) rename zero.Raven/Identity/RavenUserStore(TUser,TRole).cs => zero/Identity/ZeroUserStore(TUser,TRole).cs (80%) create mode 100644 zero/Media/IZeroMediaStoreDbProvider.cs create mode 100644 zero/Media/ImageSharp/PhysicalFileProvider.cs create mode 100644 zero/Media/ImageSharp/PresetRequestParser.cs create mode 100644 zero/Media/ImageSharp/RemoteImageCache.cs create mode 100644 zero/Media/ImageSharp/ResizeTagHelper.cs create mode 100644 zero/Media/Indexes/Media_ByChildren.cs create mode 100644 zero/Media/Indexes/Media_ByHierarchy.cs create mode 100644 zero/Media/MediaCreator.cs create mode 100644 zero/Media/MediaExtensions.cs create mode 100644 zero/Media/MediaFileSystem.cs create mode 100644 zero/Media/MediaManagement.cs create mode 100644 zero/Media/MediaManagementExtensions.cs create mode 100644 zero/Media/MediaMetadataCache.cs create mode 100644 zero/Media/MediaOptions.cs create mode 100644 zero/Media/Models/CachedMedia.cs create mode 100644 zero/Media/Models/Media.cs create mode 100644 zero/Media/Models/MediaFocalPoint.cs create mode 100644 zero/Media/Models/MediaListItem.cs create mode 100644 zero/Media/Models/MediaMetadata.cs create mode 100644 zero/Media/Models/RemoteMedia.cs create mode 100644 zero/Media/Models/Video.cs create mode 100644 zero/Media/ZeroMediaModule.cs delete mode 100644 zero/Models/IAlwaysActive.cs diff --git a/zero.Raven/DbProviders/RavenIdentityStoreDbProvider.cs b/zero.Raven/DbProviders/RavenIdentityStoreDbProvider.cs new file mode 100644 index 00000000..dafc8c6c --- /dev/null +++ b/zero.Raven/DbProviders/RavenIdentityStoreDbProvider.cs @@ -0,0 +1,37 @@ +using System.Linq.Expressions; +using Raven.Client.Documents; +using zero.Identity; + +namespace zero.Raven; + +public class RavenIdentityStoreDbProvider : IZeroIdentityStoreDbProvider +{ + protected IRavenOperations Ops { get; set; } + + + public RavenIdentityStoreDbProvider(IRavenOperations ops) + { + Ops = ops; + } + + + public Task Find(Expression> expression, CancellationToken ct = default) where T : ZeroEntity => + Ops.Session.Query().FirstOrDefaultAsync(expression, ct); + + + public async Task> FindAll(Expression> expression, CancellationToken ct = default) + where T : ZeroEntity => + await Ops.Session.Query().Where(expression).ToListAsync(ct); + + + public Task> Create(T model, CancellationToken ct = default) where T : ZeroEntity, new() => + Ops.Create(model); + + + public Task> Update(T model, CancellationToken ct = default) where T : ZeroEntity, new() => + Ops.Update(model); + + + public Task> Delete(T model, CancellationToken ct = default) where T : ZeroEntity, new() => + Ops.Delete(model); +} \ No newline at end of file diff --git a/zero.Raven/DbProviders/RavenMediaStoreDbProvider.cs b/zero.Raven/DbProviders/RavenMediaStoreDbProvider.cs new file mode 100644 index 00000000..6ae93d04 --- /dev/null +++ b/zero.Raven/DbProviders/RavenMediaStoreDbProvider.cs @@ -0,0 +1,37 @@ +using System.Linq.Expressions; +using Raven.Client.Documents; +using zero.Media; + +namespace zero.Raven; + +public class RavenMediaStoreDbProvider : IZeroMediaStoreDbProvider +{ + protected IRavenOperations Ops { get; set; } + + + public RavenMediaStoreDbProvider(IRavenOperations ops) + { + Ops = ops; + } + + + public Task Find(Expression> expression, CancellationToken ct = default) where T : ZeroEntity => + Ops.Session.Query().FirstOrDefaultAsync(expression, ct); + + + public async Task> FindAll(Expression> expression, CancellationToken ct = default) + where T : ZeroEntity => + await Ops.Session.Query().Where(expression).ToListAsync(ct); + + + public Task> Create(T model, CancellationToken ct = default) where T : ZeroEntity, new() => + Ops.Create(model); + + + public Task> Update(T model, CancellationToken ct = default) where T : ZeroEntity, new() => + Ops.Update(model); + + + public Task> Delete(T model, CancellationToken ct = default) where T : ZeroEntity, new() => + Ops.Delete(model); +} \ No newline at end of file diff --git a/zero.Raven/Operations/RavenOperations.cs b/zero.Raven/Operations/RavenOperations.cs index ed4287d7..501e71f0 100644 --- a/zero.Raven/Operations/RavenOperations.cs +++ b/zero.Raven/Operations/RavenOperations.cs @@ -81,11 +81,6 @@ public partial class RavenOperations : IRavenOperations zeroModel.Hash ??= IdGenerator.Create(); } - if (model is IAlwaysActive activeModel) - { - activeModel.IsActive = true; - } - return model; } @@ -114,7 +109,7 @@ public partial class RavenOperations : IRavenOperations /// public virtual T WhenActive(T model) where T : ZeroIdEntity, new() { - return model != null && (model is IAlwaysActive || model is not ZeroEntity || (model as ZeroEntity).IsActive) ? model : default; + return model != null && (model is not ZeroEntity || (model as ZeroEntity).IsActive) ? model : default; } } diff --git a/zero.Raven/ZeroRavenModule.cs b/zero.Raven/ZeroRavenModule.cs index 16b20b5d..ffce9b58 100644 --- a/zero.Raven/ZeroRavenModule.cs +++ b/zero.Raven/ZeroRavenModule.cs @@ -1,8 +1,11 @@ -using Microsoft.Extensions.Configuration; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Raven.Client.Documents; using Raven.Client.Documents.Indexes; using Raven.Client.Http; +using zero.Identity; +using zero.Media; namespace zero.Raven; @@ -27,6 +30,9 @@ internal class ZeroRavenModule : ZeroModule services.AddTransient(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddOptions(); services.AddOptions().Bind(configuration.GetSection("Zero:Raven")); services.ConfigureOptions(); diff --git a/zero.Raven/zero.Raven.csproj b/zero.Raven/zero.Raven.csproj index 9824c024..8e9fa9f5 100644 --- a/zero.Raven/zero.Raven.csproj +++ b/zero.Raven/zero.Raven.csproj @@ -19,4 +19,8 @@ + + + + \ No newline at end of file diff --git a/zero/Identity/IZeroIdentityStoreDbProvider.cs b/zero/Identity/IZeroIdentityStoreDbProvider.cs new file mode 100644 index 00000000..7aa230f9 --- /dev/null +++ b/zero/Identity/IZeroIdentityStoreDbProvider.cs @@ -0,0 +1,16 @@ +using System.Linq.Expressions; + +namespace zero.Identity; + +public interface IZeroIdentityStoreDbProvider +{ + Task Find(Expression> expression, CancellationToken ct = default) where T : ZeroEntity; + + Task> FindAll(Expression> expression, CancellationToken ct = default) where T : ZeroEntity; + + Task> Create(T model, CancellationToken ct = default) where T : ZeroEntity, new(); + + Task> Update(T model, CancellationToken ct = default) where T : ZeroEntity, new(); + + Task> Delete(T model, CancellationToken ct = default) where T : ZeroEntity, new(); +} \ No newline at end of file diff --git a/zero.Raven/Identity/IdentityBuilderExtensions.cs b/zero/Identity/IdentityBuilderExtensions.cs similarity index 50% rename from zero.Raven/Identity/IdentityBuilderExtensions.cs rename to zero/Identity/IdentityBuilderExtensions.cs index 9b5dddb3..864879dd 100644 --- a/zero.Raven/Identity/IdentityBuilderExtensions.cs +++ b/zero/Identity/IdentityBuilderExtensions.cs @@ -2,18 +2,19 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace zero.Raven; +namespace zero.Identity; public static class IdentityBuilderExtensions { /// - /// Adds a RavenDb implementation of identity information stores. + /// Adds an implementation of identity information stores. /// - public static IdentityBuilder AddRavenDbStores(this IdentityBuilder builder) + public static IdentityBuilder AddZeroIdentityStores(this IdentityBuilder builder) where T : class, IZeroIdentityStoreDbProvider { - Type userStoreType = typeof(RavenUserStore<,>).MakeGenericType(builder.UserType, builder.RoleType); - Type roleStoreType = typeof(RavenRoleStore<>).MakeGenericType(builder.RoleType); - + Type userStoreType = typeof(ZeroUserStore<,>).MakeGenericType(builder.UserType, builder.RoleType); + Type roleStoreType = typeof(ZeroRoleStore<>).MakeGenericType(builder.RoleType); + + builder.Services.AddScoped(); builder.Services.TryAddScoped(typeof(IUserStore<>).MakeGenericType(builder.UserType), userStoreType); builder.Services.TryAddScoped(typeof(IRoleStore<>).MakeGenericType(builder.RoleType), roleStoreType); diff --git a/zero/Identity/Models/ZeroIdentityUser.cs b/zero/Identity/Models/ZeroIdentityUser.cs index 89135488..917e6af1 100644 --- a/zero/Identity/Models/ZeroIdentityUser.cs +++ b/zero/Identity/Models/ZeroIdentityUser.cs @@ -7,11 +7,13 @@ public abstract class ZeroIdentityUser : ZeroEntity /// /// Optional username (can also be used as login when configured) /// + [PersonalData] public string Username { get; set; } /// /// E-Mail address which is also used as the username /// + [PersonalData] public string Email { get; set; } /// @@ -22,6 +24,7 @@ public abstract class ZeroIdentityUser : ZeroEntity /// /// The phone number for the user /// + [PersonalData] public string PhoneNumber { get; set; } /// diff --git a/zero.Raven/Identity/ServiceCollectionExtensions.cs b/zero/Identity/ServiceCollectionExtensions.cs similarity index 81% rename from zero.Raven/Identity/ServiceCollectionExtensions.cs rename to zero/Identity/ServiceCollectionExtensions.cs index 36dc18e0..2e087e05 100644 --- a/zero.Raven/Identity/ServiceCollectionExtensions.cs +++ b/zero/Identity/ServiceCollectionExtensions.cs @@ -5,28 +5,30 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using zero.Identity; -namespace zero.Raven; +namespace zero.Identity; public static class ServiceCollectionExtensions { /// - /// Adds the default identity system configuration for the specified User and Role types, using RavenDB as the data store. + /// Adds the default identity system configuration for the specified User and Role types /// - public static IdentityBuilder AddRavenDbIdentity( - this IServiceCollection services) - where TUser : ZeroIdentityUser, new() - where TRole : ZeroIdentityRole, new() - => services.AddIdentity(setupAction: null!); + // public static IdentityBuilder AddZeroIdentity( + // this IServiceCollection services) + // where TUser : ZeroIdentityUser, new() + // where TRole : ZeroIdentityRole, new() + // where TStore : class, IZeroIdentityStoreDbProvider + // => services.AddIdentity(setupAction: null!); /// - /// Adds and configures the identity system for the specified User and Role types, using RavenDB as the data store. + /// Adds and configures the identity system for the specified User and Role types /// - public static IdentityBuilder AddRavenDbIdentity( + public static IdentityBuilder AddZeroIdentity( this IServiceCollection services, Action setupAction ) where TUser : ZeroIdentityUser, new() where TRole : ZeroIdentityRole, new() + where TStore : class, IZeroIdentityStoreDbProvider { // Services used by identity services.AddAuthentication(options => @@ -66,8 +68,9 @@ public static class ServiceCollectionExtensions services.AddHttpContextAccessor(); // Data stores - services.TryAddScoped, RavenUserStore>(); - services.TryAddScoped, RavenRoleStore>(); + services.AddScoped(); + services.TryAddScoped, ZeroUserStore>(); + services.TryAddScoped, ZeroRoleStore>(); // Identity services services.TryAddScoped, UserValidator>(); diff --git a/zero.Raven/Identity/RavenRoleStore(TRole).cs b/zero/Identity/ZeroRoleStore(TRole).cs similarity index 86% rename from zero.Raven/Identity/RavenRoleStore(TRole).cs rename to zero/Identity/ZeroRoleStore(TRole).cs index 771a1276..4bf6f5c8 100644 --- a/zero.Raven/Identity/RavenRoleStore(TRole).cs +++ b/zero/Identity/ZeroRoleStore(TRole).cs @@ -5,23 +5,22 @@ using Raven.Client.Exceptions; using System.Security.Claims; using Microsoft.AspNetCore.Mvc.ApplicationModels; using zero.Identity; -using zero.Raven; -namespace zero.Raven; +namespace zero.Identity; -public class RavenRoleStore : +public class ZeroRoleStore : IRoleStore, IRoleClaimStore where TRole : ZeroIdentityRole, new() { protected IdentityErrorDescriber ErrorDescriber { get; private set; } - protected virtual IRavenOperations Ops { get; set; } + protected virtual IZeroIdentityStoreDbProvider Db { get; set; } - public RavenRoleStore(IRavenOperations operations, IdentityErrorDescriber describer = null) + public ZeroRoleStore(IZeroIdentityStoreDbProvider db, IdentityErrorDescriber describer = null) { - Ops = operations; + Db = db; ErrorDescriber = describer ?? new IdentityErrorDescriber(); } @@ -53,7 +52,7 @@ public class RavenRoleStore : try { - Result result = await Ops.Create(role); + Result result = await Db.Create(role); if (!result.IsSuccess) { @@ -74,7 +73,7 @@ public class RavenRoleStore : { try { - Result result = await Ops.Update(role); + Result result = await Db.Update(role); if (!result.IsSuccess) { @@ -94,7 +93,7 @@ public class RavenRoleStore : { try { - Result result = await Ops.Delete(role); + Result result = await Db.Delete(role); if (!result.IsSuccess) { @@ -139,7 +138,7 @@ public class RavenRoleStore : public async Task FindByIdAsync(string roleId, CancellationToken cancellationToken) { // TODO index - return await Ops.Session.Query().FirstOrDefaultAsync(x => x.Id == roleId, cancellationToken); + return await Db.Find(x => x.Id == roleId, cancellationToken); } @@ -147,7 +146,7 @@ public class RavenRoleStore : public async Task FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken) { // TODO index - return await Ops.Session.Query().FirstOrDefaultAsync(x => x.Name == normalizedRoleName, cancellationToken); + return await Db.Find(x => x.Name == normalizedRoleName, cancellationToken); } diff --git a/zero.Raven/Identity/RavenUserStore(TUser).cs b/zero/Identity/ZeroUserStore(TUser).cs similarity index 91% rename from zero.Raven/Identity/RavenUserStore(TUser).cs rename to zero/Identity/ZeroUserStore(TUser).cs index 420d14ce..2602b03e 100644 --- a/zero.Raven/Identity/RavenUserStore(TUser).cs +++ b/zero/Identity/ZeroUserStore(TUser).cs @@ -6,12 +6,11 @@ using System.Security.Claims; using System.Security.Cryptography; using Raven.Client.Exceptions; using zero.Identity; -using zero.Raven; -namespace zero.Raven; +namespace zero.Identity; -public partial class RavenUserStore : +public partial class ZeroUserStore : IUserStore, IUserEmailStore, IUserLockoutStore, @@ -26,9 +25,9 @@ public partial class RavenUserStore : IUserPhoneNumberStore where TUser : ZeroIdentityUser, new() { - public RavenUserStore(IRavenOperations operations, IdentityErrorDescriber describer = null) + public ZeroUserStore(IZeroIdentityStoreDbProvider db, IdentityErrorDescriber describer = null) { - Ops = operations; + Db = db; ErrorDescriber = describer ?? new IdentityErrorDescriber(); } @@ -36,7 +35,7 @@ public partial class RavenUserStore : protected IdentityErrorDescriber ErrorDescriber { get; private set; } - protected virtual IRavenOperations Ops { get; set; } + protected virtual IZeroIdentityStoreDbProvider Db { get; set; } private IdentityResult Fail(Result result) { @@ -64,7 +63,7 @@ public partial class RavenUserStore : protected virtual async Task IsEmailReserved(TUser user, CancellationToken cancellationToken = default) { // TODO index - TUser existingUser = await Ops.Session.Query().FirstOrDefaultAsync(x => x.Email == user.Email, cancellationToken); + TUser existingUser = await Db.Find(x => x.Email == user.Email, cancellationToken); return existingUser != null && existingUser.Id != user.Id; } @@ -81,7 +80,7 @@ public partial class RavenUserStore : }); } - Result result = await Ops.Create(user); + Result result = await Db.Create(user); if (!result.IsSuccess) { @@ -97,7 +96,7 @@ public partial class RavenUserStore : { try { - Result result = await Ops.Delete(user); + Result result = await Db.Delete(user); if (!result.IsSuccess) { @@ -115,7 +114,7 @@ public partial class RavenUserStore : /// public async Task UpdateAsync(TUser user, CancellationToken cancellationToken) { - TUser source = await Ops.Load(user.Id); + TUser source = await Db.Find(x => x.Id == user.Id); if (source == null) { @@ -135,7 +134,7 @@ public partial class RavenUserStore : }); } - Result result = await Ops.Update(user); + Result result = await Db.Update(user); if (!result.IsSuccess) { @@ -150,7 +149,7 @@ public partial class RavenUserStore : public async Task FindByIdAsync(string userId, CancellationToken cancellationToken) { // TODO index - return await Ops.Session.Query().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + return await Db.Find(x => x.Id == userId, cancellationToken); } @@ -158,7 +157,7 @@ public partial class RavenUserStore : public async Task FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) { // TODO index - return await Ops.Session.Query().FirstOrDefaultAsync(x => x.Username == normalizedUserName, cancellationToken); + return await Db.Find(x => x.Username == normalizedUserName, cancellationToken); } @@ -222,7 +221,7 @@ public partial class RavenUserStore : public async Task FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken) { // TODO index - return await Ops.Session.Query().FirstOrDefaultAsync(x => x.Email == normalizedEmail, cancellationToken); + return await Db.Find(x => x.Email == normalizedEmail, cancellationToken); } @@ -363,7 +362,7 @@ public partial class RavenUserStore : { UserClaim userClaim = new(claim); // TODO index - return await Ops.Session.Query().Where(x => x.Claims.Any(c => c.Type == userClaim.Type && c.Value == userClaim.Value)).ToListAsync(token: cancellationToken); + return await Db.FindAll(x => x.Claims.Any(c => c.Type == userClaim.Type && c.Value == userClaim.Value), cancellationToken); } @@ -430,7 +429,7 @@ public partial class RavenUserStore : public async Task FindByLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken) { // TODO index - return await Ops.Session.Query().FirstOrDefaultAsync(x => x.ExternalLogins.Any(l => l.LoginProvider.Equals(loginProvider, _comparer) && l.ProviderKey.Equals(providerKey, _comparer)), token: cancellationToken); + return await Db.Find(x => x.ExternalLogins.Any(l => l.LoginProvider.Equals(loginProvider, _comparer) && l.ProviderKey.Equals(providerKey, _comparer)), cancellationToken); } @@ -534,7 +533,7 @@ public partial class RavenUserStore : /// - public async Task CountCodesAsync(TUser user, CancellationToken cancellationToken) + public Task CountCodesAsync(TUser user, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -543,7 +542,7 @@ public partial class RavenUserStore : throw new ArgumentNullException(nameof(user)); } - return user.TwoFactorRecoveryCodes?.Count() ?? 0; + return Task.FromResult(user.TwoFactorRecoveryCodes?.Count() ?? 0); } diff --git a/zero.Raven/Identity/RavenUserStore(TUser,TRole).cs b/zero/Identity/ZeroUserStore(TUser,TRole).cs similarity index 80% rename from zero.Raven/Identity/RavenUserStore(TUser,TRole).cs rename to zero/Identity/ZeroUserStore(TUser,TRole).cs index a7c695ee..7106b437 100644 --- a/zero.Raven/Identity/RavenUserStore(TUser,TRole).cs +++ b/zero/Identity/ZeroUserStore(TUser,TRole).cs @@ -2,16 +2,15 @@ using Raven.Client.Documents; using Raven.Client.Documents.Linq; using zero.Identity; -using zero.Raven; -namespace zero.Raven; +namespace zero.Identity; -public partial class RavenUserStore : RavenUserStore, +public partial class ZeroUserStore : ZeroUserStore, IUserRoleStore where TUser : ZeroIdentityUser, new() where TRole : ZeroIdentityRole, new() { - public RavenUserStore(IRavenOperations operations) : base(operations) { } + public ZeroUserStore(IZeroIdentityStoreDbProvider db) : base(db) { } /// @@ -32,7 +31,7 @@ public partial class RavenUserStore : RavenUserStore, /// public async Task> GetUsersInRoleAsync(string roleName, CancellationToken cancellationToken) { - return await Ops.Session.Query().Where(x => roleName.In(x.RoleIds)).ToListAsync(); + return await Db.FindAll(x => roleName.In(x.RoleIds), cancellationToken); } diff --git a/zero/Media/IZeroMediaStoreDbProvider.cs b/zero/Media/IZeroMediaStoreDbProvider.cs new file mode 100644 index 00000000..db15fe01 --- /dev/null +++ b/zero/Media/IZeroMediaStoreDbProvider.cs @@ -0,0 +1,16 @@ +using System.Linq.Expressions; + +namespace zero.Media; + +public interface IZeroMediaStoreDbProvider +{ + Task Find(Expression> expression, CancellationToken ct = default) where T : ZeroEntity; + + Task> FindAll(Expression> expression, CancellationToken ct = default) where T : ZeroEntity; + + Task> Create(T model, CancellationToken ct = default) where T : ZeroEntity, new(); + + Task> Update(T model, CancellationToken ct = default) where T : ZeroEntity, new(); + + Task> Delete(T model, CancellationToken ct = default) where T : ZeroEntity, new(); +} \ No newline at end of file diff --git a/zero/Media/ImageSharp/PhysicalFileProvider.cs b/zero/Media/ImageSharp/PhysicalFileProvider.cs new file mode 100644 index 00000000..9366ae02 --- /dev/null +++ b/zero/Media/ImageSharp/PhysicalFileProvider.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.FileProviders; +using SixLabors.ImageSharp.Web; +using SixLabors.ImageSharp.Web.Providers; +using SixLabors.ImageSharp.Web.Resolvers; + +namespace zero.Media.ImageSharp; + +/// +/// Returns images stored in the local physical file system. +/// +public class PhysicalFileProvider : IImageProvider +{ + const string Prefix = "/:"; + + /// + /// The file provider abstraction. + /// + readonly IFileProvider _fileProvider; + + /// + /// Contains various format helper methods based on the current configuration. + /// + readonly FormatUtilities _formatUtilities; + + /// + /// Initializes a new instance of the class. + /// + /// The environment used by this middleware. + /// Contains various format helper methods based on the current configuration. + public PhysicalFileProvider(IWebHostEnvironment environment, FormatUtilities formatUtilities) + { + this._fileProvider = environment.WebRootFileProvider; + this._formatUtilities = formatUtilities; + } + + /// + public ProcessingBehavior ProcessingBehavior { get; } = ProcessingBehavior.CommandOnly; + + /// + public Func Match { get; set; } = _ => true; + + /// + public bool IsValidRequest(HttpContext context) + { + string displayUrl = context.Request.GetDisplayUrl(); + + if (!_formatUtilities.TryGetExtensionFromUri(displayUrl, out string extension)) + { + return false; + } + + return displayUrl.Contains(Prefix); + } + + /// + public Task GetAsync(HttpContext context) + { + string path = GetPath(context); + + // Path has already been correctly parsed before here. + IFileInfo fileInfo = this._fileProvider.GetFileInfo(path); + + // Check to see if the file exists. + if (!fileInfo.Exists) + { + return Task.FromResult(null); + } + + return Task.FromResult(new FileProviderImageResolver(fileInfo)); + } + + + string GetPath(HttpContext context) + { + string path = context.Request.Path.Value; + List parts = path.Split('/').ToList(); + + parts.RemoveAt(parts.Count - 2); + return String.Join('/', parts); + } +} \ No newline at end of file diff --git a/zero/Media/ImageSharp/PresetRequestParser.cs b/zero/Media/ImageSharp/PresetRequestParser.cs new file mode 100644 index 00000000..4c8a0347 --- /dev/null +++ b/zero/Media/ImageSharp/PresetRequestParser.cs @@ -0,0 +1,143 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using SixLabors.ImageSharp.Web.Commands; + +namespace zero.Media.ImageSharp; + +/// +/// Parses commands from the request querystring. +/// +public sealed class PresetRequestParser : IRequestParser +{ + MediaOptions _options; + ILogger _logger; + + const char COLON = ':'; + const string PREFIX = "/:"; + const char SLASH = '/'; + + const string MIME_AVIF = "image/avif"; + const string MIME_WEBP = "image/webp"; + + + public PresetRequestParser(ILogger logger, IOptionsMonitor monitor) + { + _logger = logger; + _options = monitor.CurrentValue; + monitor.OnChange(opts => _options = opts); + } + + + /// + public CommandCollection ParseRequestCommands(string preset, HttpContext context = null) + { + string focalPoint = null; + + int focalIndex = preset.IndexOf(COLON); + + if (focalIndex > -1) + { + focalPoint = preset.Substring(focalIndex + 1, preset.Length - focalIndex - 1); + preset = preset.Substring(0, focalIndex); + } + + if (preset == "{rx}") + { + return new(); + } + + if (!_options.ImageSharp.Presets.TryGetValue(preset, out string[] transformed)) + { + _logger.LogWarning("Could not load imaging preset {preset}", preset); + return new(); + } + + Dictionary filters = transformed.Select(x => x.Split("=", 2)).ToDictionary(x => x[0], x => x[1]); + + if (focalPoint != null) + { + filters["rxy"] = focalPoint; + } + else + { + filters["ranchor"] = "center"; + } + + FallbackFormat fallbackFormat = FindFallbackFormatInContext(context); + + // fall back to webp, as avif is not supported yet by ImageSharp + if (!filters.ContainsKey("format") && fallbackFormat != FallbackFormat.None) + { + filters["format"] = "webp"; + } + + if (!filters.ContainsKey("quality")) + { + filters["quality"] = _options.ImageSharp.DefaultQuality.ToString(); + } + + CommandCollection collection = new(); + + foreach (KeyValuePair filter in filters) + { + collection.Add(filter); + } + + return collection; + } + + + /// + public FallbackFormat FindFallbackFormatInContext(HttpContext context) + { + string acceptKey = Microsoft.Net.Http.Headers.HeaderNames.Accept; + + if (context == null || context.Request == null || !context.Request.Headers.ContainsKey(acceptKey)) + { + return FallbackFormat.None; + } + + string acceptHeader = context.Request.Headers[acceptKey].ToString(); + + if (acceptHeader.IsNullOrEmpty()) + { + return FallbackFormat.None; + } + if (acceptHeader.Contains(MIME_AVIF)) + { + return FallbackFormat.Avif; + } + if (acceptHeader.Contains(MIME_WEBP)) + { + return FallbackFormat.Webp; + } + return FallbackFormat.None; + } + + + /// + public CommandCollection ParseRequestCommands(HttpContext context) + { + string path = context.Request.Path.Value; + int startSlash = path.IndexOf(PREFIX); + int lastSlash = path.LastIndexOf(SLASH); + + if (startSlash < 0) + { + return new(); + } + + string preset = path.Substring(startSlash + PREFIX.Length, lastSlash - startSlash - PREFIX.Length); + return ParseRequestCommands(preset, context); + } + + + public enum FallbackFormat + { + None = 0, + Webp = 1, + Avif = 2 + } +} diff --git a/zero/Media/ImageSharp/RemoteImageCache.cs b/zero/Media/ImageSharp/RemoteImageCache.cs new file mode 100644 index 00000000..44087804 --- /dev/null +++ b/zero/Media/ImageSharp/RemoteImageCache.cs @@ -0,0 +1,105 @@ +// using Microsoft.Extensions.Logging; +// using Microsoft.Extensions.Options; +// +// namespace zero.Media.ImageSharp; +// +// public class RemoteImageCache : IRemoteImageCache +// { +// protected IBoldCache Cache { get; set; } +// +// protected IWebRootFileSystem FileSystem { get; set; } +// +// protected ILogger Logger { get; set; } +// +// protected ImagingRemoteCacheOptions Options { get; set; } +// +// public const string CACHE_PREFIX = "remote:images:"; +// +// +// public RemoteImageCache(ILogger logger, IOptionsMonitor monitor, IBoldCache cache, IWebRootFileSystem fileSystem) +// { +// Cache = cache; +// Logger = logger; +// FileSystem = fileSystem; +// Options = monitor.CurrentValue.RemoteCache; +// monitor.OnChange(opts => Options = opts.RemoteCache); +// } +// +// +// /// +// public async Task Resolve(string url) +// { +// if (url.IsNullOrWhiteSpace()) +// { +// return null; +// } +// +// url = url.Replace("hqdefault.jpg", "maxresdefault.jpg"); +// +// if (!Options.Enabled) +// { +// Logger.LogWarning("Tried to call remote image cache although the cache is disabled (url {url}", url); +// return null; +// } +// +// string key = CACHE_PREFIX + url; +// +// // try to find cached version first +// Media media = Cache.Get(key); +// +// if (media != null) +// { +// return media; +// } +// +// // download and store in filesystem +// try +// { +// using Stream fileStream = await DownloadFile(url); +// string fileName = url.Split('/').LastOrDefault(); +// +// string hash = Guid.NewGuid().ToString(); +// string path = "/uploads/" + hash + "/" + fileName; +// +// await FileSystem.CreateFile(path, fileStream); +// +// media = new() +// { +// Source = path, +// RemotePath = url +// }; +// } +// catch (Exception ex) +// { +// Logger.LogError(ex, "Could not cache remote file {url}", url); +// return null; +// } +// +// // cache file for one day +// if (media != null) +// { +// Cache.Set(key, media, TimeSpan.FromDays(1)); +// } +// +// return media; +// } +// +// +// /// +// /// Downloads a remote file +// /// +// protected async Task DownloadFile(string url, CancellationToken token = default) +// { +// using HttpClient http = new(); +// return await http.GetStreamAsync(url, token); +// } +// } +// +// +// public interface IRemoteImageCache +// { +// /// +// /// Resolves or caches an external media file +// /// +// Task Resolve(string url); +// } \ No newline at end of file diff --git a/zero/Media/ImageSharp/ResizeTagHelper.cs b/zero/Media/ImageSharp/ResizeTagHelper.cs new file mode 100644 index 00000000..a63a737b --- /dev/null +++ b/zero/Media/ImageSharp/ResizeTagHelper.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace zero.TagHelpers; + +[HtmlTargetElement(Attributes = "zero-resize")] +public class ResizeTagHelper : TagHelper +{ + [HtmlAttributeName("src")] + public string Src { get; set; } + + [HtmlAttributeName("zero-resize")] + public string Preset { get; set; } + + [ViewContext] + public ViewContext ViewContext { get; set; } + + protected IFileVersionProvider FileVersionProvider { get; set; } + + + public ResizeTagHelper(IFileVersionProvider fileVersionProvider) + { + FileVersionProvider = fileVersionProvider; + } + + + public override void Process(TagHelperContext context, TagHelperOutput output) + { + string src = FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, Src).Resize(Preset); + output.Attributes.RemoveAll("zero-resize"); + output.Attributes.SetAttribute("src", src); + } +} \ No newline at end of file diff --git a/zero/Media/Indexes/Media_ByChildren.cs b/zero/Media/Indexes/Media_ByChildren.cs new file mode 100644 index 00000000..0f32893b --- /dev/null +++ b/zero/Media/Indexes/Media_ByChildren.cs @@ -0,0 +1,38 @@ +// using Raven.Client.Documents.Indexes; +// +// namespace zero.Media; +// +// public class Media_ByChildren : ZeroMultiMapIndex +// { +// public class Result : ZeroIdEntity, ISupportsDbConventions +// { +// public string ParentId { get; set; } +// +// public int ChildrenCount { get; set; } +// +// public string[] ChildrenIds { get; set; } +// } +// +// +// protected override void Create() +// { +// AddMap(items => items.Select(item => new Result() +// { +// Id = item.Id, +// ParentId = item.ParentId, +// ChildrenCount = 1, +// ChildrenIds = new string[] { } +// })); +// +// Reduce = results => results.GroupBy(x => new { x.ParentId }).Select(group => new Result() +// { +// Id = null, +// ParentId = group.Key.ParentId, +// ChildrenCount = group.Sum(x => x.ChildrenCount), +// ChildrenIds = group.Select(x => x.Id).ToArray() +// }); +// +// StoreAllFields(FieldStorage.Yes); +// Index(x => x.ParentId, FieldIndexing.Exact); +// } +// } \ No newline at end of file diff --git a/zero/Media/Indexes/Media_ByHierarchy.cs b/zero/Media/Indexes/Media_ByHierarchy.cs new file mode 100644 index 00000000..46ce620a --- /dev/null +++ b/zero/Media/Indexes/Media_ByHierarchy.cs @@ -0,0 +1,45 @@ +// using Raven.Client.Documents.Indexes; +// +// namespace zero.Media; +// +// public class Media_ByHierarchy : ZeroIndex +// { +// public class Result : ZeroIdEntity, ISupportsDbConventions +// { +// public string Name { get; set; } +// +// public List Path { get; set; } = new List(); +// +// public string[] PathIds { get; set; } = Array.Empty(); +// } +// +// +// public class PathResult +// { +// public string Id { get; set; } +// +// public string Name { get; set; } +// } +// +// +// protected override void Create() +// { +// Map = items => items +// .Select(item => new +// { +// Item = item, +// Path = Recurse(item, x => LoadDocument(x.ParentId)).Where(x => x != null && x.Id != null && x.Id != item.Id).Reverse() +// }) +// .Select(item => new Result +// { +// Id = item.Item.Id, +// Name = item.Item.Name, +// Path = item.Path.Select(current => new PathResult() { Id = current.Id, Name = current.Name }).ToList(), +// PathIds = item.Path.Select(current => current.Id).ToArray() +// }); +// +// StoreAllFields(FieldStorage.Yes); +// Index("PathIds", FieldIndexing.Exact); +// //Index(x => x.ChannelId, FieldIndexing.Exact); +// } +// } diff --git a/zero/Media/MediaCreator.cs b/zero/Media/MediaCreator.cs new file mode 100644 index 00000000..563a33be --- /dev/null +++ b/zero/Media/MediaCreator.cs @@ -0,0 +1,147 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System.IO; + +namespace zero.Media; + +public class MediaCreator : IMediaCreator +{ + protected IMediaFileSystem FileSystem { get; set; } + + protected MediaOptions Options { get; set; } + + + public MediaCreator(IMediaFileSystem fileSystem, IZeroOptions options) + { + FileSystem = fileSystem; + Options = options.For(); + } + + + /// + public async Task> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default) + { + string fileExtension = Path.GetExtension(filename); + string normalizedFilename = Safenames.File(filename); + + bool isImage = Options.AllowedImageFileExtensions.Contains(fileExtension, StringComparer.InvariantCultureIgnoreCase); + bool isDocument = !isImage && Options.AllowedOtherFileExtensions.Contains(fileExtension, StringComparer.InvariantCultureIgnoreCase); + + if (!isImage && !isDocument) + { + // TODO error + return Result.Fail("ERROR"); + } + + Media model = new(); + model.Name = normalizedFilename; + model.ParentId = folderId; + model.IsFolder = false; + + // create directory which hosts the media file + // the media directory is a flat folder where each folder contains one media file (+ thumbs) + string directory = await CreateDirectory(cancellationToken); + model.FileId = directory; + + // the path is a combination of the folder name + filename, e.g. 129021309123/myfile.jpg + model.Path = directory + '/' + normalizedFilename; + + // store the file in the attached file system + // we do not need to check if the file already exists in the file system + // as we only use newly created directories, therefore a collision shouldn't happen + await FileSystem.CreateFile(model.Path, fileStream, cancellationToken: cancellationToken); + + // we need file metadata to get info about file size and the physical path for image modification + IFileMeta fileInfo = await FileSystem.GetFileInfo(model.Path, cancellationToken); + model.Size = fileInfo.Length; + + if (isImage) + { + using Image image = await Image.LoadAsync(fileInfo.AbsolutePath); + model.Metadata = GetImageMetadata(image); + + string extension = Path.GetExtension(model.Path); + + foreach ((string key, ResizeOptions opts) in Options.Thumbnails) + { + Image imageFrame = image.Frames.Count > 1 ? image.Frames.CloneFrame(0) : image.Clone(); + imageFrame.Mutate(x => x.Resize(opts)); + + using MemoryStream stream = new(); + await imageFrame.SaveAsync(stream, new PngEncoder(), cancellationToken); + + stream.Position = 0; + + string thumbFilename = normalizedFilename.TrimEnd(extension) + "." + Safenames.File(key) + ".png"; + string path = directory + '/' + thumbFilename; + + await FileSystem.CreateFile(path, stream, cancellationToken: cancellationToken); + + model.Thumbnails[key] = path; + } + } + + return Result.Success(model); + } + + + /// + protected virtual MediaMetadata GetImageMetadata(Image image) + { + PngMetadata pngMetadata = image.Metadata.GetPngMetadata(); + //WebpMetadata webpMetadata = image.Metadata.GetWebpMetadata(); + + return new MediaMetadata() + { + Width = image.Width, + Height = image.Height, + //ImageTakenDate = 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 + }; + } + + + /// + /// Create a new directory for a file. + /// This method is collision-aware and repeats until a directory can be created. + /// + protected virtual async Task CreateDirectory(CancellationToken cancellationToken = default) + { + try + { + string directoryName = GetNewDirectoryName(); + await FileSystem.CreateDirectory(directoryName, cancellationToken); + return directoryName; + } + catch (FileSystemException ex) when (ex.Message.Contains("already exists")) + { + return await CreateDirectory(cancellationToken); + } + } + + + /// + /// Builds a directory name for a new media item + /// + protected virtual string GetNewDirectoryName() + { + return Guid.NewGuid().ToString(); + } +} + + + +public interface IMediaCreator +{ + /// + /// Uploads a file by using the attached file system + /// + /// A temporary media file which can be persisted in a store + Task> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/zero/Media/MediaExtensions.cs b/zero/Media/MediaExtensions.cs new file mode 100644 index 00000000..dc8e3004 --- /dev/null +++ b/zero/Media/MediaExtensions.cs @@ -0,0 +1,72 @@ +using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using SixLabors.ImageSharp.Web; +using System.Numerics; + +namespace zero.Media; + +public static class MediaExtensions +{ + public static SixLabors.ImageSharp.PointF GetFocalPointF(this MediaFocalPoint focalPoint) + { + if (focalPoint == null) + { + focalPoint = new() { Left = 0.5m, Top = 0.5m }; + } + + Vector2 center = new((float)focalPoint.Left, (float)focalPoint.Top); + return ExifOrientationUtilities.Transform(center, Vector2.Zero, Vector2.One, ExifOrientationMode.Unknown); + } + + + public static string CssObjectPosition(this MediaFocalPoint focalPoint) + { + string values = String.Empty; + + if (focalPoint != null && (focalPoint.Left != 0.5m || focalPoint.Top != 0.5m)) + { + Func round = input => Decimal.Round(input, 0, MidpointRounding.ToEven).ToString(); + values = String.Format("{0}% {1}%", round(focalPoint.Left * 100), round(focalPoint.Top * 100)); + } + + return values; + } + + + public static string Resize(this string path, string preset) => path.Resize(preset, null); + + public static string Resize(this string path, string preset, MediaFocalPoint focalPoint = null) + { + if (path.IsNullOrEmpty()) + { + return String.Empty; + } + + List parts = path.Split('/').ToList(); + parts.Insert(parts.Count - 1, ":" + preset + StringifyFocalPoint(focalPoint)); + + if (parts[0] == "http:" || parts[0] == "https:") + { + return String.Join('/', parts); + } + + return String.Join('/', parts).EnsureStartsWith('/'); + } + + + static string StringifyFocalPoint(MediaFocalPoint focalPoint = null) + { + string xy = String.Empty; + + if (focalPoint != null && (focalPoint.Left != 0.5m || focalPoint.Top != 0.5m)) + { + Func round = input => + { + return Decimal.Round(input, 2, MidpointRounding.ToEven).ToString().Replace(',', '.'); + }; + + xy = String.Format(":{0},{1}", round(focalPoint.Left), round(focalPoint.Top)); + } + + return xy; + } +} \ No newline at end of file diff --git a/zero/Media/MediaFileSystem.cs b/zero/Media/MediaFileSystem.cs new file mode 100644 index 00000000..d90dfdde --- /dev/null +++ b/zero/Media/MediaFileSystem.cs @@ -0,0 +1,39 @@ +namespace zero.Media; + +public class MediaFileSystem : PhysicalFileSystem, IMediaFileSystem +{ + protected string PublicPathPrefix { get; } + + + public MediaFileSystem(string root, string publicPathPrefix) : base(root) + { + PublicPathPrefix = (publicPathPrefix ?? String.Empty).EnsureEndsWith('/'); + } + + + /// + public override string MapToPublicPath(string path) + { + if (path.IsNullOrEmpty()) + { + return null; + } + return PublicPathPrefix + path.TrimStart('/'); + } + + + /// + public bool IsMediaPath(string path) + { + return path.StartsWith(PublicPathPrefix, StringComparison.InvariantCultureIgnoreCase); + } +} + + +public interface IMediaFileSystem : IFileSystem +{ + /// + /// Determine whether the given path is part of the media file system + /// + bool IsMediaPath(string path); +} \ No newline at end of file diff --git a/zero/Media/MediaManagement.cs b/zero/Media/MediaManagement.cs new file mode 100644 index 00000000..094482a8 --- /dev/null +++ b/zero/Media/MediaManagement.cs @@ -0,0 +1,205 @@ +using System.IO; + +namespace zero.Media; + +public class MediaManagement : IMediaManagement +{ + protected IMediaFileSystem FileSystem { get; set; } + + protected IZeroMediaStoreDbProvider Db { get; set; } + + protected IMediaCreator Creator { get; set; } + + + public MediaManagement(IMediaFileSystem fileSystem, IZeroMediaStoreDbProvider db, IMediaCreator creator) + { + FileSystem = fileSystem; + Db = db; + Creator = creator; + } + + + + /// + public virtual string GetPublicFilePath(Media file, string thumbnailKey = null) + { + string path = file?.Path; + + if (!thumbnailKey.IsNullOrWhiteSpace()) + { + path = file?.Thumbnails?.GetValueOrDefault(thumbnailKey); + } + + if (path.IsNullOrEmpty()) + { + return null; + } + + return FileSystem.MapToPublicPath(path); + } + + + /// + public virtual async Task GetFileStream(Media file, string thumbnailKey = null) + { + if (file == null) + { + return null; + } + + string path = file?.Path; + + if (!thumbnailKey.IsNullOrWhiteSpace()) + { + path = file?.Thumbnails?.GetValueOrDefault(thumbnailKey); + } + + if (path.IsNullOrEmpty()) + { + return null; + } + + return await FileSystem.StreamFile(path); + } + + + /// + public virtual async Task GetFile(string id) + { + Media file = await Db.Find(x => x.Id == id); + return file != null && !file.IsFolder ? file : null; + } + + + /// + public virtual async Task> UpdateFile(Media file) + { + // TODO check new file/image/media + return await Db.Update(file); + } + + + /// + public virtual async Task> DeleteFile(Media file) + { + // TODO delete in file system + return await Db.Delete(file); + } + + + /// + public virtual async Task> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default) + { + Result result = await Creator.UploadFile(fileStream, filename, folderId, cancellationToken); + + if (!result.IsSuccess) + { + return result; + } + + return await Db.Create(result.Model); + } + + + /// + public virtual async Task GetFolder(string id) + { + Media folder = await Db.Find(x => x.Id == id); + return folder != null && folder.IsFolder ? folder : null; + } + + + /// + public virtual async Task> CreateFolder(Media folder) + { + folder.IsActive = true; + folder.IsFolder = true; + return await Db.Create(folder); + } + + + /// + public virtual async Task> CreateFolder(string name, string parentId = null) + { + Media media = new(); + media.Name = name; + media.ParentId = parentId; + return await CreateFolder(media); + } + + + /// + public virtual async Task> UpdateFolder(Media folder) + { + return await Db.Update(folder); + } + + + /// + // public virtual async Task> DeleteFolder(Media folder) + // { + // // TODO recursive + // return await Store.DeleteWithDescendants(folder); + // } +} + + +public interface IMediaManagement +{ + /// + /// Get publicly accessible file path for a media file + /// + /// The media file + /// An optional thumbnail key which returns the path to a generated thumbnail + string GetPublicFilePath(Media file, string thumbnailKey = null); + + /// + /// Get file stream for a media file (stream has to be disposed manually) + /// + Task GetFileStream(Media file, string thumbnailKey = null); + + /// + /// Get a media file by id + /// + Task GetFile(string id); + + /// + /// Update and store a media file + /// + Task> UpdateFile(Media file); + + /// + /// Deletes a media file (collection entry as well as physical file) + /// + Task> DeleteFile(Media file); + + /// + /// Uploads a file and persists it + /// + Task> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default); + + /// + /// Get a media folder by id + /// + Task GetFolder(string id); + + /// + /// Creates a new media folder + /// + Task> CreateFolder(Media folder); + + /// + /// Creates a new media folder + /// + Task> CreateFolder(string name, string parentId = null); + + /// + /// Rename and store a media folder + /// + Task> UpdateFolder(Media folder); + + /// + /// Deletes a folder, as well as all descendant folders and files + /// + //Task> DeleteFolder(Media folder); +} diff --git a/zero/Media/MediaManagementExtensions.cs b/zero/Media/MediaManagementExtensions.cs new file mode 100644 index 00000000..b701b7e5 --- /dev/null +++ b/zero/Media/MediaManagementExtensions.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Http; +using System.IO; + +namespace zero.Media +{ + public static class MediaManagementExtensions + { + /// + /// Get publicly accessible file path for a media file + /// + /// ID of a media file + /// An optional thumbnail key which returns the path to a generated thumbnail + public static async Task GetPublicFilePath(this IMediaManagement media, string mediaId, string thumbnailKey = null) + { + Media file = await media.GetFile(mediaId); + if (file == null) + { + return null; + } + return media.GetPublicFilePath(file, thumbnailKey); + } + + /// + /// Get file stream for a media file (stream has to be disposed manually) + /// + public static async Task GetFileStream(this IMediaManagement media, string mediaId, string thumbnailKey = null) + { + Media file = await media.GetFile(mediaId); + if (file == null) + { + return null; + } + return await media.GetFileStream(file, thumbnailKey); + } + + /// + /// Uploads a file and persists it + /// + public static async Task> UploadFile(this IMediaManagement media, IFormFile formFile, string folderId = null, CancellationToken cancellationToken = default) + { + using Stream stream = formFile.OpenReadStream(); + return await media.UploadFile(stream, formFile.FileName, folderId, cancellationToken); + } + + + /// + /// Uploads a file and persists it + /// + public static async Task> UploadFile(this IMediaManagement media, byte[] fileBytes, string filename, string folderId = null, CancellationToken cancellationToken = default) + { + using Stream stream = new MemoryStream(fileBytes); + return await media.UploadFile(stream, filename, folderId, cancellationToken); + } + + + /// + /// Rename and store a media folder + /// + public static async Task> RenameFolder(this IMediaManagement media, Media folder, string newName) + { + folder.Name = newName; + return await media.UpdateFolder(folder); + } + + + /// + /// Rename and store a media folder + /// + public static async Task> RenameFolder(this IMediaManagement media, string folderId, string newName) + { + Media folder = await media.GetFolder(folderId); + if (folder == null) + { + return Result.Fail("@errors.idnotfound"); + } + + return await RenameFolder(media, folder, newName); + } + + + /// + /// Move a media folder to a new parent + /// + public static async Task> MoveFolder(this IMediaManagement media, Media folder, string newParentId) + { + folder.ParentId = newParentId; + return await media.UpdateFolder(folder); + } + + + /// + /// Move a media folder to a new parent + /// + public static async Task> MoveFolder(this IMediaManagement media, string folderId, string newParentId) + { + Media folder = await media.GetFolder(folderId); + if (folder == null) + { + return Result.Fail("@errors.idnotfound"); + } + + return await MoveFolder(media, folder, newParentId); + } + + + /// + /// Deletes a folder by id + /// + // public static async Task> DeleteFolder(this IMediaManagement media, string folderId) + // { + // Media folder = await media.GetFolder(folderId); + // if (folder == null) + // { + // return Result.Fail("@errors.idnotfound"); + // } + // + // return await media.DeleteFolder(folder); + // } + } +} diff --git a/zero/Media/MediaMetadataCache.cs b/zero/Media/MediaMetadataCache.cs new file mode 100644 index 00000000..2350d52c --- /dev/null +++ b/zero/Media/MediaMetadataCache.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Caching.Memory; + +namespace zero.Media; + +public class MediaMetadataCache : IMediaMetadataCache +{ + private const string PREFIX = "zero/media/"; + + protected IMemoryCache Cache { get; set; } + + + public MediaMetadataCache(IMemoryCache cache) + { + Cache = cache; + } + + + /// + public bool TryGet(string id, out CachedMedia media) + { + MediaCacheEntry entry = Cache.Get(Key(id)); + + media = entry != null ? CachedMedia.Create(entry) : null; + return entry != null; + } + + + /// + public void Set(Media media) + { + MediaCacheEntry entry = MediaCacheEntry.Create(media); + Cache.Set(Key(media.Id), entry, new MemoryCacheEntryOptions() + { + SlidingExpiration = TimeSpan.FromSeconds(60 * 60 * 24) + }); + } + + + /// + /// Generate cache key from media Id + /// + static string Key(string id) + { + return PREFIX + id; + } +} + + +public interface IMediaMetadataCache +{ + /// + /// Get a cached and reduced version of a media entity + /// + bool TryGet(string id, out CachedMedia media); + + /// + /// Store a media entity in cache + /// + void Set(Media media); +} \ No newline at end of file diff --git a/zero/Media/MediaOptions.cs b/zero/Media/MediaOptions.cs new file mode 100644 index 00000000..b70e522f --- /dev/null +++ b/zero/Media/MediaOptions.cs @@ -0,0 +1,43 @@ +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Web.Caching; + +namespace zero.Media; + +public class MediaOptions +{ + public string FolderPath { get; set; } + + public string PublicPathPrefix { get; set; } = string.Empty; + + public List AllowedOtherFileExtensions { get; set; } + + public List AllowedImageFileExtensions { get; set; } + + public Dictionary Thumbnails { get; set; } + + public ImageSharpOptions ImageSharp { get; set; } = new(); +} + + +public class ImageSharpOptions +{ + public ImagingSharpRemoteCacheOptions RemoteCache { get; set; } = new(); + + public PhysicalFileSystemCacheOptions Cache { get; set; } = new(); + + public Dictionary Presets { get; set; } = new(); + + public int DefaultQuality { get; set; } = 75; +} + + +public class ImagingSharpRemoteCacheOptions +{ + public bool Enabled { get; set; } = true; + + public int ExpiresInHours { get; set; } = -1; + + public string MediaFolder { get; set; } = "/media/_remote/"; + + public string KeyMapFile { get; set; } = "/Config/remotekeys.json"; +} \ No newline at end of file diff --git a/zero/Media/Models/CachedMedia.cs b/zero/Media/Models/CachedMedia.cs new file mode 100644 index 00000000..413d7173 --- /dev/null +++ b/zero/Media/Models/CachedMedia.cs @@ -0,0 +1,70 @@ +namespace zero.Media; + +/// +/// A cached media entity comes from the IMemoryCache +/// and is therefore reduced to only necessary properties +/// (Id, ParentId, Path, Metadata, AltText, Caption) +/// +public sealed class CachedMedia : Media +{ + internal static CachedMedia Create(MediaCacheEntry entry) + { + return new() + { + Id = entry.Id, + ParentId = entry.ParentId, + Path = entry.Path, + AltText = entry.AltText, + Caption = entry.Caption, + Metadata = entry.Metadata + }; + } +} + + +internal class MediaCacheEntry +{ + /// + /// Id of the entity + /// + public string Id { get; set; } + + /// + /// Id of the parent folder + /// + public string ParentId { get; set; } + + /// + /// Path of the media item + /// + public string Path { get; set; } + + /// + /// Alternative text which is used when the media can't be loaded + /// + public string AltText { get; set; } + + /// + /// Additional caption text + /// + public string Caption { get; set; } + + /// + /// Meta data for images/videos + /// + public MediaMetadata Metadata { get; set; } + + + internal static MediaCacheEntry Create(Media entry) + { + return new() + { + Id = entry.Id, + ParentId = entry.ParentId, + Path = entry.Path, + AltText = entry.AltText, + Caption = entry.Caption, + Metadata = entry.Metadata + }; + } +} \ No newline at end of file diff --git a/zero/Media/Models/Media.cs b/zero/Media/Models/Media.cs new file mode 100644 index 00000000..3117244d --- /dev/null +++ b/zero/Media/Models/Media.cs @@ -0,0 +1,53 @@ +namespace zero.Media; + +/// +/// A media file (can contain an image or other media like videos and documents) +/// +public class Media : ZeroEntity, ISupportsTrees +{ + /// + /// Whether this media item is a folder or a file + /// + public bool IsFolder { get; set; } + + /// + /// Id/name of the phyiscal folder which is stored on disk/cloud + /// + public string FileId { get; set; } + + /// + /// Id of the parent folder + /// + public string ParentId { get; set; } + + /// + /// Path of the media item + /// + public string Path { get; set; } + + /// + /// Filesize in bytes + /// + public long Size { get; set; } + + /// + /// Alternative text which is used when the media can't be loaded + /// + public string AltText { get; set; } + + /// + /// Additional caption text + /// + public string Caption { get; set; } + + /// + /// Meta data for images/videos + /// + public MediaMetadata Metadata { get; set; } + + /// + /// Define custom thumbnails which are generated on upload + /// (see IZeroOptions.For().Thumbnails) + /// + public Dictionary Thumbnails { get; set; } = new(); +} \ No newline at end of file diff --git a/zero/Media/Models/MediaFocalPoint.cs b/zero/Media/Models/MediaFocalPoint.cs new file mode 100644 index 00000000..6a89bc3b --- /dev/null +++ b/zero/Media/Models/MediaFocalPoint.cs @@ -0,0 +1,13 @@ +namespace zero.Media; + +/// +/// The focal point sets the point of interest in an image with x/y coordinates from 0-1 +/// +public class MediaFocalPoint +{ + public decimal Left { get; set; } + + public decimal Top { get; set; } + + public bool NotDefault() => Left != 0.5m || Top != 0.5m; +} diff --git a/zero/Media/Models/MediaListItem.cs b/zero/Media/Models/MediaListItem.cs new file mode 100644 index 00000000..6d7d92cc --- /dev/null +++ b/zero/Media/Models/MediaListItem.cs @@ -0,0 +1,24 @@ +namespace zero.Media; + +public class MediaListItem : ZeroIdEntity +{ + public string ParentId { get; set; } + + public string Name { get; set; } + + public DateTimeOffset CreatedDate { get; set; } + + public bool IsFolder { get; set; } + + public string Image { get; set; } + + public long Size { get; set; } + + public int Children { get; set; } + + public bool HasTransparency { get; set; } + + public float AspectRatio { get; set; } + + public bool IsShared { get; set; } +} diff --git a/zero/Media/Models/MediaMetadata.cs b/zero/Media/Models/MediaMetadata.cs new file mode 100644 index 00000000..3542a87e --- /dev/null +++ b/zero/Media/Models/MediaMetadata.cs @@ -0,0 +1,27 @@ +namespace zero.Media; + +/// +/// Metadata for images/videos +/// +public class MediaMetadata +{ + /// + /// Optional focal point for an image + /// + public MediaFocalPoint FocalPoint { get; set; } + + /// + /// Width in pixels + /// + public int Width { get; set; } + + /// + /// Height in pixels + /// + public int Height { get; set; } + + /// + /// Whether this image contains transparent pixels + /// + public bool HasTransparency { get; set; } +} \ No newline at end of file diff --git a/zero/Media/Models/RemoteMedia.cs b/zero/Media/Models/RemoteMedia.cs new file mode 100644 index 00000000..19c11161 --- /dev/null +++ b/zero/Media/Models/RemoteMedia.cs @@ -0,0 +1,8 @@ +namespace zero.Media; + +public class RemoteMedia : Media +{ + public string Source { get; set; } + + public string RemotePath { get; set; } +} \ No newline at end of file diff --git a/zero/Media/Models/Video.cs b/zero/Media/Models/Video.cs new file mode 100644 index 00000000..648c2f41 --- /dev/null +++ b/zero/Media/Models/Video.cs @@ -0,0 +1,24 @@ +namespace zero.Media; + +public class Video +{ + public VideoProvider Provider { get; set; } + + public string VideoId { get; set; } + + public string VideoUrl { get; set; } + + public string VideoPreviewImageUrl { get; set; } + + public string Title { get; set; } + + public string PreviewImageId { get; set; } +} + + +public enum VideoProvider +{ + Html, + Youtube, + Vimeo +} diff --git a/zero/Media/ZeroMediaModule.cs b/zero/Media/ZeroMediaModule.cs new file mode 100644 index 00000000..5508a535 --- /dev/null +++ b/zero/Media/ZeroMediaModule.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using SixLabors.ImageSharp.Processing; +using System.IO; +using SixLabors.ImageSharp.Web.Caching; +using SixLabors.ImageSharp.Web.DependencyInjection; +using zero.Media.ImageSharp; + +namespace zero.Media; + +internal class ZeroMediaModule : ZeroModule +{ + public override void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddImageSharp() + .SetRequestParser() + .ClearProviders() + .AddProvider() + .Configure(configuration.GetSection("Zero:Media:ImageSharp:Cache")); + + //configuration.AddJsonFile(Path.Combine(Directory.GetCurrentDirectory(), "Config/imaging.json"), true, true); + //configuration.AddJsonFile(Path.Combine(Directory.GetCurrentDirectory(), $"Config/imaging.{builder.Environment.EnvironmentName}.json"), true); + + services.AddSingleton(svc => + { + IOptions options = svc.GetRequiredService>(); + IWebHostEnvironment env = svc.GetRequiredService(); + return new(Path.Combine(env.WebRootPath, options.Value.FolderPath), options.Value.PublicPathPrefix + options.Value.FolderPath.EnsureStartsWith('/')); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddOptions().Bind(configuration.GetSection("Zero:Media")).Configure(opts => + { + opts.FolderPath = "media"; + opts.AllowedOtherFileExtensions = new() { ".pdf", ".docx", ".doc" }; + opts.AllowedImageFileExtensions = new() { ".jpg", ".jpeg", ".png", ".bmp", ".webp", ".gif", ".avif" }; + // opts.Thumbnails = new() + // { + // { "thumb", new ResizeOptions() { Size = new(100, 100), Mode = ResizeMode.Max } }, + // { "preview", new ResizeOptions() { Size = new(210, 210), Mode = ResizeMode.Min } } + // }; + }); + + // services.Configure(opts => + // { + // RavenOptions raven = opts.For(); + // raven.Indexes.Add(); + // raven.Indexes.Add(); + // }); + } +} \ No newline at end of file diff --git a/zero/Models/IAlwaysActive.cs b/zero/Models/IAlwaysActive.cs deleted file mode 100644 index 34f7dbce..00000000 --- a/zero/Models/IAlwaysActive.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace zero.Models; - -/// -/// Entities decorated with this interface are always set to IsActive=true -/// -public interface IAlwaysActive -{ - /// - /// Whether the entity is visible in the frontend - /// - bool IsActive { get; set; } -} \ No newline at end of file diff --git a/zero/Usings.cs b/zero/Usings.cs index 80fffe84..d36137d7 100644 --- a/zero/Usings.cs +++ b/zero/Usings.cs @@ -13,9 +13,10 @@ global using zero.Extensions; global using zero.FileStorage; global using zero.Models; global using zero.Modules; +global using zero.Media; global using zero.Rendering; global using zero.Validation; global using zero.Localization; global using zero.Context; global using zero.Communication; -global using zero.Assemblies; \ No newline at end of file +global using zero.Assemblies; diff --git a/zero/ZeroBuilder.cs b/zero/ZeroBuilder.cs index deafab10..10e319de 100644 --- a/zero/ZeroBuilder.cs +++ b/zero/ZeroBuilder.cs @@ -45,7 +45,7 @@ public class ZeroBuilder Modules.Add(); //Modules.Add(); //Modules.Add(); - //Modules.Add(); + Modules.Add(); //Modules.Add(); Modules.Add(); //Modules.Add(); diff --git a/zero/zero.csproj b/zero/zero.csproj index 05fd771f..96ead0c2 100644 --- a/zero/zero.csproj +++ b/zero/zero.csproj @@ -18,6 +18,7 @@ +