add media module + imagesharp integration
This commit is contained in:
@@ -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<T> Find<T>(Expression<Func<T, bool>> expression, CancellationToken ct = default) where T : ZeroEntity =>
|
||||
Ops.Session.Query<T>().FirstOrDefaultAsync(expression, ct);
|
||||
|
||||
|
||||
public async Task<IList<T>> FindAll<T>(Expression<Func<T, bool>> expression, CancellationToken ct = default)
|
||||
where T : ZeroEntity =>
|
||||
await Ops.Session.Query<T>().Where(expression).ToListAsync(ct);
|
||||
|
||||
|
||||
public Task<Result<T>> Create<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new() =>
|
||||
Ops.Create(model);
|
||||
|
||||
|
||||
public Task<Result<T>> Update<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new() =>
|
||||
Ops.Update(model);
|
||||
|
||||
|
||||
public Task<Result<T>> Delete<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new() =>
|
||||
Ops.Delete(model);
|
||||
}
|
||||
@@ -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<T> Find<T>(Expression<Func<T, bool>> expression, CancellationToken ct = default) where T : ZeroEntity =>
|
||||
Ops.Session.Query<T>().FirstOrDefaultAsync(expression, ct);
|
||||
|
||||
|
||||
public async Task<IList<T>> FindAll<T>(Expression<Func<T, bool>> expression, CancellationToken ct = default)
|
||||
where T : ZeroEntity =>
|
||||
await Ops.Session.Query<T>().Where(expression).ToListAsync(ct);
|
||||
|
||||
|
||||
public Task<Result<T>> Create<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new() =>
|
||||
Ops.Create(model);
|
||||
|
||||
|
||||
public Task<Result<T>> Update<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new() =>
|
||||
Ops.Update(model);
|
||||
|
||||
|
||||
public Task<Result<T>> Delete<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new() =>
|
||||
Ops.Delete(model);
|
||||
}
|
||||
@@ -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
|
||||
/// <inheritdoc />
|
||||
public virtual T WhenActive<T>(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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IRavenOperations, RavenOperations>();
|
||||
services.AddScoped<IInterceptors, Interceptors>();
|
||||
|
||||
services.AddScoped<IZeroIdentityStoreDbProvider, RavenIdentityStoreDbProvider>();
|
||||
services.AddScoped<IZeroMediaStoreDbProvider, RavenMediaStoreDbProvider>();
|
||||
|
||||
services.AddOptions<FlavorOptions>();
|
||||
services.AddOptions<RavenOptions>().Bind(configuration.GetSection("Zero:Raven"));
|
||||
services.ConfigureOptions<ConfigureFlavorJsonOptions>();
|
||||
|
||||
@@ -19,4 +19,8 @@
|
||||
<ProjectReference Include="..\zero\zero.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace zero.Identity;
|
||||
|
||||
public interface IZeroIdentityStoreDbProvider
|
||||
{
|
||||
Task<T> Find<T>(Expression<Func<T, bool>> expression, CancellationToken ct = default) where T : ZeroEntity;
|
||||
|
||||
Task<IList<T>> FindAll<T>(Expression<Func<T, bool>> expression, CancellationToken ct = default) where T : ZeroEntity;
|
||||
|
||||
Task<Result<T>> Create<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new();
|
||||
|
||||
Task<Result<T>> Update<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new();
|
||||
|
||||
Task<Result<T>> Delete<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new();
|
||||
}
|
||||
+7
-6
@@ -2,18 +2,19 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace zero.Raven;
|
||||
namespace zero.Identity;
|
||||
|
||||
public static class IdentityBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a RavenDb implementation of identity information stores.
|
||||
/// Adds an implementation of identity information stores.
|
||||
/// </summary>
|
||||
public static IdentityBuilder AddRavenDbStores(this IdentityBuilder builder)
|
||||
public static IdentityBuilder AddZeroIdentityStores<T>(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<IZeroIdentityStoreDbProvider, T>();
|
||||
builder.Services.TryAddScoped(typeof(IUserStore<>).MakeGenericType(builder.UserType), userStoreType);
|
||||
builder.Services.TryAddScoped(typeof(IRoleStore<>).MakeGenericType(builder.RoleType), roleStoreType);
|
||||
|
||||
@@ -7,11 +7,13 @@ public abstract class ZeroIdentityUser : ZeroEntity
|
||||
/// <summary>
|
||||
/// Optional username (can also be used as login when configured)
|
||||
/// </summary>
|
||||
[PersonalData]
|
||||
public string Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// E-Mail address which is also used as the username
|
||||
/// </summary>
|
||||
[PersonalData]
|
||||
public string Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
@@ -22,6 +24,7 @@ public abstract class ZeroIdentityUser : ZeroEntity
|
||||
/// <summary>
|
||||
/// The phone number for the user
|
||||
/// </summary>
|
||||
[PersonalData]
|
||||
public string PhoneNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
+14
-11
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public static IdentityBuilder AddRavenDbIdentity<TUser, TRole>(
|
||||
this IServiceCollection services)
|
||||
where TUser : ZeroIdentityUser, new()
|
||||
where TRole : ZeroIdentityRole, new()
|
||||
=> services.AddIdentity<TUser, TRole>(setupAction: null!);
|
||||
// public static IdentityBuilder AddZeroIdentity<TUser, TRole, TStore>(
|
||||
// this IServiceCollection services)
|
||||
// where TUser : ZeroIdentityUser, new()
|
||||
// where TRole : ZeroIdentityRole, new()
|
||||
// where TStore : class, IZeroIdentityStoreDbProvider
|
||||
// => services.AddIdentity<TUser, TRole>(setupAction: null!);
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public static IdentityBuilder AddRavenDbIdentity<TUser, TRole>(
|
||||
public static IdentityBuilder AddZeroIdentity<TUser, TRole, TStore>(
|
||||
this IServiceCollection services,
|
||||
Action<IdentityOptions> 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<IUserStore<TUser>, RavenUserStore<TUser>>();
|
||||
services.TryAddScoped<IRoleStore<TRole>, RavenRoleStore<TRole>>();
|
||||
services.AddScoped<IZeroIdentityStoreDbProvider, TStore>();
|
||||
services.TryAddScoped<IUserStore<TUser>, ZeroUserStore<TUser>>();
|
||||
services.TryAddScoped<IRoleStore<TRole>, ZeroRoleStore<TRole>>();
|
||||
|
||||
// Identity services
|
||||
services.TryAddScoped<IUserValidator<TUser>, UserValidator<TUser>>();
|
||||
+10
-11
@@ -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<TRole> :
|
||||
public class ZeroRoleStore<TRole> :
|
||||
IRoleStore<TRole>,
|
||||
IRoleClaimStore<TRole>
|
||||
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<TRole> :
|
||||
|
||||
try
|
||||
{
|
||||
Result<TRole> result = await Ops.Create(role);
|
||||
Result<TRole> result = await Db.Create(role);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
@@ -74,7 +73,7 @@ public class RavenRoleStore<TRole> :
|
||||
{
|
||||
try
|
||||
{
|
||||
Result<TRole> result = await Ops.Update(role);
|
||||
Result<TRole> result = await Db.Update(role);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
@@ -94,7 +93,7 @@ public class RavenRoleStore<TRole> :
|
||||
{
|
||||
try
|
||||
{
|
||||
Result<TRole> result = await Ops.Delete(role);
|
||||
Result<TRole> result = await Db.Delete(role);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
@@ -139,7 +138,7 @@ public class RavenRoleStore<TRole> :
|
||||
public async Task<TRole> FindByIdAsync(string roleId, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO index
|
||||
return await Ops.Session.Query<TRole>().FirstOrDefaultAsync(x => x.Id == roleId, cancellationToken);
|
||||
return await Db.Find<TRole>(x => x.Id == roleId, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +146,7 @@ public class RavenRoleStore<TRole> :
|
||||
public async Task<TRole> FindByNameAsync(string normalizedRoleName, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO index
|
||||
return await Ops.Session.Query<TRole>().FirstOrDefaultAsync(x => x.Name == normalizedRoleName, cancellationToken);
|
||||
return await Db.Find<TRole>(x => x.Name == normalizedRoleName, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
+17
-18
@@ -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<TUser> :
|
||||
public partial class ZeroUserStore<TUser> :
|
||||
IUserStore<TUser>,
|
||||
IUserEmailStore<TUser>,
|
||||
IUserLockoutStore<TUser>,
|
||||
@@ -26,9 +25,9 @@ public partial class RavenUserStore<TUser> :
|
||||
IUserPhoneNumberStore<TUser>
|
||||
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<TUser> :
|
||||
|
||||
protected IdentityErrorDescriber ErrorDescriber { get; private set; }
|
||||
|
||||
protected virtual IRavenOperations Ops { get; set; }
|
||||
protected virtual IZeroIdentityStoreDbProvider Db { get; set; }
|
||||
|
||||
private IdentityResult Fail(Result<TUser> result)
|
||||
{
|
||||
@@ -64,7 +63,7 @@ public partial class RavenUserStore<TUser> :
|
||||
protected virtual async Task<bool> IsEmailReserved(TUser user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO index
|
||||
TUser existingUser = await Ops.Session.Query<TUser>().FirstOrDefaultAsync(x => x.Email == user.Email, cancellationToken);
|
||||
TUser existingUser = await Db.Find<TUser>(x => x.Email == user.Email, cancellationToken);
|
||||
return existingUser != null && existingUser.Id != user.Id;
|
||||
}
|
||||
|
||||
@@ -81,7 +80,7 @@ public partial class RavenUserStore<TUser> :
|
||||
});
|
||||
}
|
||||
|
||||
Result<TUser> result = await Ops.Create(user);
|
||||
Result<TUser> result = await Db.Create(user);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
@@ -97,7 +96,7 @@ public partial class RavenUserStore<TUser> :
|
||||
{
|
||||
try
|
||||
{
|
||||
Result<TUser> result = await Ops.Delete(user);
|
||||
Result<TUser> result = await Db.Delete(user);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
@@ -115,7 +114,7 @@ public partial class RavenUserStore<TUser> :
|
||||
/// <inheritdoc />
|
||||
public async Task<IdentityResult> UpdateAsync(TUser user, CancellationToken cancellationToken)
|
||||
{
|
||||
TUser source = await Ops.Load<TUser>(user.Id);
|
||||
TUser source = await Db.Find<TUser>(x => x.Id == user.Id);
|
||||
|
||||
if (source == null)
|
||||
{
|
||||
@@ -135,7 +134,7 @@ public partial class RavenUserStore<TUser> :
|
||||
});
|
||||
}
|
||||
|
||||
Result<TUser> result = await Ops.Update(user);
|
||||
Result<TUser> result = await Db.Update(user);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
@@ -150,7 +149,7 @@ public partial class RavenUserStore<TUser> :
|
||||
public async Task<TUser> FindByIdAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO index
|
||||
return await Ops.Session.Query<TUser>().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
return await Db.Find<TUser>(x => x.Id == userId, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +157,7 @@ public partial class RavenUserStore<TUser> :
|
||||
public async Task<TUser> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO index
|
||||
return await Ops.Session.Query<TUser>().FirstOrDefaultAsync(x => x.Username == normalizedUserName, cancellationToken);
|
||||
return await Db.Find<TUser>(x => x.Username == normalizedUserName, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -222,7 +221,7 @@ public partial class RavenUserStore<TUser> :
|
||||
public async Task<TUser> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO index
|
||||
return await Ops.Session.Query<TUser>().FirstOrDefaultAsync(x => x.Email == normalizedEmail, cancellationToken);
|
||||
return await Db.Find<TUser>(x => x.Email == normalizedEmail, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -363,7 +362,7 @@ public partial class RavenUserStore<TUser> :
|
||||
{
|
||||
UserClaim userClaim = new(claim);
|
||||
// TODO index
|
||||
return await Ops.Session.Query<TUser>().Where(x => x.Claims.Any(c => c.Type == userClaim.Type && c.Value == userClaim.Value)).ToListAsync(token: cancellationToken);
|
||||
return await Db.FindAll<TUser>(x => x.Claims.Any(c => c.Type == userClaim.Type && c.Value == userClaim.Value), cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -430,7 +429,7 @@ public partial class RavenUserStore<TUser> :
|
||||
public async Task<TUser> FindByLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO index
|
||||
return await Ops.Session.Query<TUser>().FirstOrDefaultAsync(x => x.ExternalLogins.Any(l => l.LoginProvider.Equals(loginProvider, _comparer) && l.ProviderKey.Equals(providerKey, _comparer)), token: cancellationToken);
|
||||
return await Db.Find<TUser>(x => x.ExternalLogins.Any(l => l.LoginProvider.Equals(loginProvider, _comparer) && l.ProviderKey.Equals(providerKey, _comparer)), cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -534,7 +533,7 @@ public partial class RavenUserStore<TUser> :
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> CountCodesAsync(TUser user, CancellationToken cancellationToken)
|
||||
public Task<int> CountCodesAsync(TUser user, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -543,7 +542,7 @@ public partial class RavenUserStore<TUser> :
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
}
|
||||
|
||||
return user.TwoFactorRecoveryCodes?.Count() ?? 0;
|
||||
return Task.FromResult<int>(user.TwoFactorRecoveryCodes?.Count() ?? 0);
|
||||
}
|
||||
|
||||
|
||||
+4
-5
@@ -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<TUser, TRole> : RavenUserStore<TUser>,
|
||||
public partial class ZeroUserStore<TUser, TRole> : ZeroUserStore<TUser>,
|
||||
IUserRoleStore<TUser>
|
||||
where TUser : ZeroIdentityUser, new()
|
||||
where TRole : ZeroIdentityRole, new()
|
||||
{
|
||||
public RavenUserStore(IRavenOperations operations) : base(operations) { }
|
||||
public ZeroUserStore(IZeroIdentityStoreDbProvider db) : base(db) { }
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -32,7 +31,7 @@ public partial class RavenUserStore<TUser, TRole> : RavenUserStore<TUser>,
|
||||
/// <inheritdoc />
|
||||
public async Task<IList<TUser>> GetUsersInRoleAsync(string roleName, CancellationToken cancellationToken)
|
||||
{
|
||||
return await Ops.Session.Query<TUser>().Where(x => roleName.In(x.RoleIds)).ToListAsync();
|
||||
return await Db.FindAll<TUser>(x => roleName.In(x.RoleIds), cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace zero.Media;
|
||||
|
||||
public interface IZeroMediaStoreDbProvider
|
||||
{
|
||||
Task<T> Find<T>(Expression<Func<T, bool>> expression, CancellationToken ct = default) where T : ZeroEntity;
|
||||
|
||||
Task<IList<T>> FindAll<T>(Expression<Func<T, bool>> expression, CancellationToken ct = default) where T : ZeroEntity;
|
||||
|
||||
Task<Result<T>> Create<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new();
|
||||
|
||||
Task<Result<T>> Update<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new();
|
||||
|
||||
Task<Result<T>> Delete<T>(T model, CancellationToken ct = default) where T : ZeroEntity, new();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Returns images stored in the local physical file system.
|
||||
/// </summary>
|
||||
public class PhysicalFileProvider : IImageProvider
|
||||
{
|
||||
const string Prefix = "/:";
|
||||
|
||||
/// <summary>
|
||||
/// The file provider abstraction.
|
||||
/// </summary>
|
||||
readonly IFileProvider _fileProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Contains various format helper methods based on the current configuration.
|
||||
/// </summary>
|
||||
readonly FormatUtilities _formatUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PhysicalFileSystemProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="environment">The environment used by this middleware.</param>
|
||||
/// <param name="formatUtilities">Contains various format helper methods based on the current configuration.</param>
|
||||
public PhysicalFileProvider(IWebHostEnvironment environment, FormatUtilities formatUtilities)
|
||||
{
|
||||
this._fileProvider = environment.WebRootFileProvider;
|
||||
this._formatUtilities = formatUtilities;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ProcessingBehavior ProcessingBehavior { get; } = ProcessingBehavior.CommandOnly;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Func<HttpContext, bool> Match { get; set; } = _ => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsValidRequest(HttpContext context)
|
||||
{
|
||||
string displayUrl = context.Request.GetDisplayUrl();
|
||||
|
||||
if (!_formatUtilities.TryGetExtensionFromUri(displayUrl, out string extension))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return displayUrl.Contains(Prefix);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IImageResolver> 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<IImageResolver>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<IImageResolver>(new FileProviderImageResolver(fileInfo));
|
||||
}
|
||||
|
||||
|
||||
string GetPath(HttpContext context)
|
||||
{
|
||||
string path = context.Request.Path.Value;
|
||||
List<string> parts = path.Split('/').ToList();
|
||||
|
||||
parts.RemoveAt(parts.Count - 2);
|
||||
return String.Join('/', parts);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Parses commands from the request querystring.
|
||||
/// </summary>
|
||||
public sealed class PresetRequestParser : IRequestParser
|
||||
{
|
||||
MediaOptions _options;
|
||||
ILogger<PresetRequestParser> _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<PresetRequestParser> logger, IOptionsMonitor<MediaOptions> monitor)
|
||||
{
|
||||
_logger = logger;
|
||||
_options = monitor.CurrentValue;
|
||||
monitor.OnChange(opts => _options = opts);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
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<string, string> 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<string, string> filter in filters)
|
||||
{
|
||||
collection.Add(filter);
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<RemoteImageCache> Logger { get; set; }
|
||||
//
|
||||
// protected ImagingRemoteCacheOptions Options { get; set; }
|
||||
//
|
||||
// public const string CACHE_PREFIX = "remote:images:";
|
||||
//
|
||||
//
|
||||
// public RemoteImageCache(ILogger<RemoteImageCache> logger, IOptionsMonitor<ImagingOptions> monitor, IBoldCache cache, IWebRootFileSystem fileSystem)
|
||||
// {
|
||||
// Cache = cache;
|
||||
// Logger = logger;
|
||||
// FileSystem = fileSystem;
|
||||
// Options = monitor.CurrentValue.RemoteCache;
|
||||
// monitor.OnChange(opts => Options = opts.RemoteCache);
|
||||
// }
|
||||
//
|
||||
//
|
||||
// /// <inheritdoc />
|
||||
// public async Task<Media> 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<Media>(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;
|
||||
// }
|
||||
//
|
||||
//
|
||||
// /// <summary>
|
||||
// /// Downloads a remote file
|
||||
// /// </summary>
|
||||
// protected async Task<Stream> DownloadFile(string url, CancellationToken token = default)
|
||||
// {
|
||||
// using HttpClient http = new();
|
||||
// return await http.GetStreamAsync(url, token);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//
|
||||
// public interface IRemoteImageCache
|
||||
// {
|
||||
// /// <summary>
|
||||
// /// Resolves or caches an external media file
|
||||
// /// </summary>
|
||||
// Task<Media> Resolve(string url);
|
||||
// }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// using Raven.Client.Documents.Indexes;
|
||||
//
|
||||
// namespace zero.Media;
|
||||
//
|
||||
// public class Media_ByChildren : ZeroMultiMapIndex<Media_ByChildren.Result>
|
||||
// {
|
||||
// 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<Media>(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);
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,45 @@
|
||||
// using Raven.Client.Documents.Indexes;
|
||||
//
|
||||
// namespace zero.Media;
|
||||
//
|
||||
// public class Media_ByHierarchy : ZeroIndex<Media, Media_ByHierarchy.Result>
|
||||
// {
|
||||
// public class Result : ZeroIdEntity, ISupportsDbConventions
|
||||
// {
|
||||
// public string Name { get; set; }
|
||||
//
|
||||
// public List<PathResult> Path { get; set; } = new List<PathResult>();
|
||||
//
|
||||
// public string[] PathIds { get; set; } = Array.Empty<string>();
|
||||
// }
|
||||
//
|
||||
//
|
||||
// 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<Media>(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);
|
||||
// }
|
||||
// }
|
||||
@@ -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<MediaOptions>();
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<Media>> 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<Media>.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<Rgba32> image = await Image.LoadAsync<Rgba32>(fileInfo.AbsolutePath);
|
||||
model.Metadata = GetImageMetadata(image);
|
||||
|
||||
string extension = Path.GetExtension(model.Path);
|
||||
|
||||
foreach ((string key, ResizeOptions opts) in Options.Thumbnails)
|
||||
{
|
||||
Image<Rgba32> 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<Media>.Success(model);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
protected virtual MediaMetadata GetImageMetadata(Image<Rgba32> 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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Create a new directory for a file.
|
||||
/// This method is collision-aware and repeats until a directory can be created.
|
||||
/// </summary>
|
||||
protected virtual async Task<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Builds a directory name for a new media item
|
||||
/// </summary>
|
||||
protected virtual string GetNewDirectoryName()
|
||||
{
|
||||
return Guid.NewGuid().ToString();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public interface IMediaCreator
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads a file by using the attached file system
|
||||
/// </summary>
|
||||
/// <returns>A temporary media file which can be persisted in a store</returns>
|
||||
Task<Result<Media>> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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<decimal, string> 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<string> 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<decimal, string> round = input =>
|
||||
{
|
||||
return Decimal.Round(input, 2, MidpointRounding.ToEven).ToString().Replace(',', '.');
|
||||
};
|
||||
|
||||
xy = String.Format(":{0},{1}", round(focalPoint.Left), round(focalPoint.Top));
|
||||
}
|
||||
|
||||
return xy;
|
||||
}
|
||||
}
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string MapToPublicPath(string path)
|
||||
{
|
||||
if (path.IsNullOrEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return PublicPathPrefix + path.TrimStart('/');
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsMediaPath(string path)
|
||||
{
|
||||
return path.StartsWith(PublicPathPrefix, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public interface IMediaFileSystem : IFileSystem
|
||||
{
|
||||
/// <summary>
|
||||
/// Determine whether the given path is part of the media file system
|
||||
/// </summary>
|
||||
bool IsMediaPath(string path);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<Stream> 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);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<Media> GetFile(string id)
|
||||
{
|
||||
Media file = await Db.Find<Media>(x => x.Id == id);
|
||||
return file != null && !file.IsFolder ? file : null;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<Result<Media>> UpdateFile(Media file)
|
||||
{
|
||||
// TODO check new file/image/media
|
||||
return await Db.Update(file);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<Result<Media>> DeleteFile(Media file)
|
||||
{
|
||||
// TODO delete in file system
|
||||
return await Db.Delete(file);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<Result<Media>> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Result<Media> result = await Creator.UploadFile(fileStream, filename, folderId, cancellationToken);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return await Db.Create(result.Model);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<Media> GetFolder(string id)
|
||||
{
|
||||
Media folder = await Db.Find<Media>(x => x.Id == id);
|
||||
return folder != null && folder.IsFolder ? folder : null;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<Result<Media>> CreateFolder(Media folder)
|
||||
{
|
||||
folder.IsActive = true;
|
||||
folder.IsFolder = true;
|
||||
return await Db.Create(folder);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<Result<Media>> CreateFolder(string name, string parentId = null)
|
||||
{
|
||||
Media media = new();
|
||||
media.Name = name;
|
||||
media.ParentId = parentId;
|
||||
return await CreateFolder(media);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual async Task<Result<Media>> UpdateFolder(Media folder)
|
||||
{
|
||||
return await Db.Update(folder);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
// public virtual async Task<Result<string[]>> DeleteFolder(Media folder)
|
||||
// {
|
||||
// // TODO recursive
|
||||
// return await Store.DeleteWithDescendants(folder);
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
public interface IMediaManagement
|
||||
{
|
||||
/// <summary>
|
||||
/// Get publicly accessible file path for a media file
|
||||
/// </summary>
|
||||
/// <param name="file">The media file</param>
|
||||
/// <param name="thumbnailKey">An optional thumbnail key which returns the path to a generated thumbnail</param>
|
||||
string GetPublicFilePath(Media file, string thumbnailKey = null);
|
||||
|
||||
/// <summary>
|
||||
/// Get file stream for a media file (stream has to be disposed manually)
|
||||
/// </summary>
|
||||
Task<Stream> GetFileStream(Media file, string thumbnailKey = null);
|
||||
|
||||
/// <summary>
|
||||
/// Get a media file by id
|
||||
/// </summary>
|
||||
Task<Media> GetFile(string id);
|
||||
|
||||
/// <summary>
|
||||
/// Update and store a media file
|
||||
/// </summary>
|
||||
Task<Result<Media>> UpdateFile(Media file);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a media file (collection entry as well as physical file)
|
||||
/// </summary>
|
||||
Task<Result<Media>> DeleteFile(Media file);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a file and persists it
|
||||
/// </summary>
|
||||
Task<Result<Media>> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get a media folder by id
|
||||
/// </summary>
|
||||
Task<Media> GetFolder(string id);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new media folder
|
||||
/// </summary>
|
||||
Task<Result<Media>> CreateFolder(Media folder);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new media folder
|
||||
/// </summary>
|
||||
Task<Result<Media>> CreateFolder(string name, string parentId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Rename and store a media folder
|
||||
/// </summary>
|
||||
Task<Result<Media>> UpdateFolder(Media folder);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a folder, as well as all descendant folders and files
|
||||
/// </summary>
|
||||
//Task<Result<string[]>> DeleteFolder(Media folder);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.IO;
|
||||
|
||||
namespace zero.Media
|
||||
{
|
||||
public static class MediaManagementExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Get publicly accessible file path for a media file
|
||||
/// </summary>
|
||||
/// <param name="mediaId">ID of a media file</param>
|
||||
/// <param name="thumbnailKey">An optional thumbnail key which returns the path to a generated thumbnail</param>
|
||||
public static async Task<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get file stream for a media file (stream has to be disposed manually)
|
||||
/// </summary>
|
||||
public static async Task<Stream> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a file and persists it
|
||||
/// </summary>
|
||||
public static async Task<Result<Media>> 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);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a file and persists it
|
||||
/// </summary>
|
||||
public static async Task<Result<Media>> 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);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Rename and store a media folder
|
||||
/// </summary>
|
||||
public static async Task<Result<Media>> RenameFolder(this IMediaManagement media, Media folder, string newName)
|
||||
{
|
||||
folder.Name = newName;
|
||||
return await media.UpdateFolder(folder);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Rename and store a media folder
|
||||
/// </summary>
|
||||
public static async Task<Result<Media>> RenameFolder(this IMediaManagement media, string folderId, string newName)
|
||||
{
|
||||
Media folder = await media.GetFolder(folderId);
|
||||
if (folder == null)
|
||||
{
|
||||
return Result<Media>.Fail("@errors.idnotfound");
|
||||
}
|
||||
|
||||
return await RenameFolder(media, folder, newName);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Move a media folder to a new parent
|
||||
/// </summary>
|
||||
public static async Task<Result<Media>> MoveFolder(this IMediaManagement media, Media folder, string newParentId)
|
||||
{
|
||||
folder.ParentId = newParentId;
|
||||
return await media.UpdateFolder(folder);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Move a media folder to a new parent
|
||||
/// </summary>
|
||||
public static async Task<Result<Media>> MoveFolder(this IMediaManagement media, string folderId, string newParentId)
|
||||
{
|
||||
Media folder = await media.GetFolder(folderId);
|
||||
if (folder == null)
|
||||
{
|
||||
return Result<Media>.Fail("@errors.idnotfound");
|
||||
}
|
||||
|
||||
return await MoveFolder(media, folder, newParentId);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a folder by id
|
||||
/// </summary>
|
||||
// public static async Task<Result<string[]>> DeleteFolder(this IMediaManagement media, string folderId)
|
||||
// {
|
||||
// Media folder = await media.GetFolder(folderId);
|
||||
// if (folder == null)
|
||||
// {
|
||||
// return Result<string[]>.Fail("@errors.idnotfound");
|
||||
// }
|
||||
//
|
||||
// return await media.DeleteFolder(folder);
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGet(string id, out CachedMedia media)
|
||||
{
|
||||
MediaCacheEntry entry = Cache.Get<MediaCacheEntry>(Key(id));
|
||||
|
||||
media = entry != null ? CachedMedia.Create(entry) : null;
|
||||
return entry != null;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Set(Media media)
|
||||
{
|
||||
MediaCacheEntry entry = MediaCacheEntry.Create(media);
|
||||
Cache.Set(Key(media.Id), entry, new MemoryCacheEntryOptions()
|
||||
{
|
||||
SlidingExpiration = TimeSpan.FromSeconds(60 * 60 * 24)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Generate cache key from media Id
|
||||
/// </summary>
|
||||
static string Key(string id)
|
||||
{
|
||||
return PREFIX + id;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public interface IMediaMetadataCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a cached and reduced version of a media entity
|
||||
/// </summary>
|
||||
bool TryGet(string id, out CachedMedia media);
|
||||
|
||||
/// <summary>
|
||||
/// Store a media entity in cache
|
||||
/// </summary>
|
||||
void Set(Media media);
|
||||
}
|
||||
@@ -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<string> AllowedOtherFileExtensions { get; set; }
|
||||
|
||||
public List<string> AllowedImageFileExtensions { get; set; }
|
||||
|
||||
public Dictionary<string, ResizeOptions> 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<string, string[]> 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";
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace zero.Media;
|
||||
|
||||
/// <summary>
|
||||
/// A cached media entity comes from the IMemoryCache
|
||||
/// and is therefore reduced to only necessary properties
|
||||
/// (Id, ParentId, Path, Metadata, AltText, Caption)
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// Id of the entity
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Id of the parent folder
|
||||
/// </summary>
|
||||
public string ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path of the media item
|
||||
/// </summary>
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Alternative text which is used when the media can't be loaded
|
||||
/// </summary>
|
||||
public string AltText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional caption text
|
||||
/// </summary>
|
||||
public string Caption { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Meta data for images/videos
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace zero.Media;
|
||||
|
||||
/// <summary>
|
||||
/// A media file (can contain an image or other media like videos and documents)
|
||||
/// </summary>
|
||||
public class Media : ZeroEntity, ISupportsTrees
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether this media item is a folder or a file
|
||||
/// </summary>
|
||||
public bool IsFolder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Id/name of the phyiscal folder which is stored on disk/cloud
|
||||
/// </summary>
|
||||
public string FileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Id of the parent folder
|
||||
/// </summary>
|
||||
public string ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path of the media item
|
||||
/// </summary>
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Filesize in bytes
|
||||
/// </summary>
|
||||
public long Size { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Alternative text which is used when the media can't be loaded
|
||||
/// </summary>
|
||||
public string AltText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional caption text
|
||||
/// </summary>
|
||||
public string Caption { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Meta data for images/videos
|
||||
/// </summary>
|
||||
public MediaMetadata Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Define custom thumbnails which are generated on upload
|
||||
/// (see IZeroOptions.For<MediaOptions>().Thumbnails)
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Thumbnails { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace zero.Media;
|
||||
|
||||
/// <summary>
|
||||
/// The focal point sets the point of interest in an image with x/y coordinates from 0-1
|
||||
/// </summary>
|
||||
public class MediaFocalPoint
|
||||
{
|
||||
public decimal Left { get; set; }
|
||||
|
||||
public decimal Top { get; set; }
|
||||
|
||||
public bool NotDefault() => Left != 0.5m || Top != 0.5m;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace zero.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for images/videos
|
||||
/// </summary>
|
||||
public class MediaMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional focal point for an image
|
||||
/// </summary>
|
||||
public MediaFocalPoint FocalPoint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Width in pixels
|
||||
/// </summary>
|
||||
public int Width { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Height in pixels
|
||||
/// </summary>
|
||||
public int Height { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this image contains transparent pixels
|
||||
/// </summary>
|
||||
public bool HasTransparency { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace zero.Media;
|
||||
|
||||
public class RemoteMedia : Media
|
||||
{
|
||||
public string Source { get; set; }
|
||||
|
||||
public string RemotePath { get; set; }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<PresetRequestParser>()
|
||||
.ClearProviders()
|
||||
.AddProvider<PhysicalFileProvider>()
|
||||
.Configure<PhysicalFileSystemCacheOptions>(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<IMediaFileSystem, MediaFileSystem>(svc =>
|
||||
{
|
||||
IOptions<MediaOptions> options = svc.GetRequiredService<IOptions<MediaOptions>>();
|
||||
IWebHostEnvironment env = svc.GetRequiredService<IWebHostEnvironment>();
|
||||
return new(Path.Combine(env.WebRootPath, options.Value.FolderPath), options.Value.PublicPathPrefix + options.Value.FolderPath.EnsureStartsWith('/'));
|
||||
});
|
||||
|
||||
services.AddScoped<IMediaCreator, MediaCreator>();
|
||||
services.AddScoped<IMediaManagement, MediaManagement>();
|
||||
services.AddScoped<IMediaMetadataCache, MediaMetadataCache>();
|
||||
|
||||
services.AddOptions<MediaOptions>().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<ZeroOptions>(opts =>
|
||||
// {
|
||||
// RavenOptions raven = opts.For<RavenOptions>();
|
||||
// raven.Indexes.Add<Media_ByChildren>();
|
||||
// raven.Indexes.Add<Media_ByHierarchy>();
|
||||
// });
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace zero.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Entities decorated with this interface are always set to IsActive=true
|
||||
/// </summary>
|
||||
public interface IAlwaysActive
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the entity is visible in the frontend
|
||||
/// </summary>
|
||||
bool IsActive { get; set; }
|
||||
}
|
||||
+2
-1
@@ -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;
|
||||
global using zero.Assemblies;
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ public class ZeroBuilder
|
||||
Modules.Add<ZeroLocalizationModule>();
|
||||
//Modules.Add<ZeroMailModule>();
|
||||
//Modules.Add<ZeroMapperModule>();
|
||||
//Modules.Add<ZeroMediaModule>();
|
||||
Modules.Add<ZeroMediaModule>();
|
||||
//Modules.Add<ZeroPageModule>();
|
||||
Modules.Add<ZeroRenderingModule>();
|
||||
//Modules.Add<ZeroRoutingModule>();
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="7.0.0" />
|
||||
<PackageReference Include="RavenDB.Client" Version="5.4.5" />
|
||||
<PackageReference Include="FluentValidation" Version="11.4.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Web" Version="2.0.2" />
|
||||
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user