add media module + imagesharp integration

This commit is contained in:
2022-12-10 20:17:22 +01:00
parent 14328ffced
commit 313bd59b98
38 changed files with 1587 additions and 72 deletions
@@ -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);
}
+1 -6
View File
@@ -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;
}
}
+7 -1
View File
@@ -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>();
+4
View File
@@ -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();
}
@@ -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);
+3
View File
@@ -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>
@@ -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>>();
@@ -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);
}
@@ -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);
}
@@ -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);
}
+16
View File
@@ -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
}
}
+105
View File
@@ -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);
// }
+34
View File
@@ -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);
}
}
+38
View File
@@ -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);
// }
// }
+45
View File
@@ -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);
// }
// }
+147
View File
@@ -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);
}
+72
View File
@@ -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;
}
}
+39
View File
@@ -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);
}
+205
View File
@@ -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);
}
+120
View File
@@ -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);
// }
}
}
+60
View File
@@ -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);
}
+43
View File
@@ -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";
}
+70
View File
@@ -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
};
}
}
+53
View File
@@ -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();
}
+13
View File
@@ -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;
}
+24
View File
@@ -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; }
}
+27
View File
@@ -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; }
}
+8
View File
@@ -0,0 +1,8 @@
namespace zero.Media;
public class RemoteMedia : Media
{
public string Source { get; set; }
public string RemotePath { get; set; }
}
+24
View File
@@ -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
}
+56
View File
@@ -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>();
// });
}
}
-12
View File
@@ -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
View File
@@ -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
View File
@@ -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>();
+1
View File
@@ -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>