diff --git a/zero.Core/Unimplemented/PreviewApi.cs b/zero.Core/Unimplemented/PreviewApi.cs
deleted file mode 100644
index 7363049e..00000000
--- a/zero.Core/Unimplemented/PreviewApi.cs
+++ /dev/null
@@ -1,75 +0,0 @@
-//using System;
-//using System.Threading.Tasks;
-//using zero.Core.Collections;
-//using zero.Core.Entities;
-//using zero.Core.Extensions;
-//using Rc = Raven.Client;
-
-//namespace zero.Core.Api
-//{
-// public class PreviewApi : BackofficeApi, IPreviewApi
-// {
-// Preview Blueprint;
-
-// public PreviewApi(IStoreContext store, Preview blueprint) : base(store)
-// {
-// Blueprint = blueprint;
-// }
-
-
-// ///
-// public async Task> Add(TEntity model) where TEntity : ZeroEntity
-// {
-// return await Update(null, model);
-// }
-
-
-// ///
-// public async Task> Update(string id, TEntity model) where TEntity : ZeroEntity
-// {
-// Preview entity = id == null ? await GetById(id) : Blueprint.Clone();
-// entity.Content = model;
-// entity.OriginalId = model.Id;
-// entity.Name = model.Name;
-
-// return await SaveModel(entity, meta: x =>
-// {
-// x[Rc.Constants.Documents.Metadata.Expires] = GetExpiry(model);
-// });
-// }
-
-
-// ///
-// public async Task GetById(string id)
-// {
-// return await GetById(id);
-// }
-
-
-// ///
-// /// Get preview expiration for a document
-// ///
-// DateTime GetExpiry(ZeroEntity model)
-// {
-// return DateTime.Now.AddHours(1);
-// }
-// }
-
-// public interface IPreviewApi
-// {
-// ///
-// /// Adds an entity to the preview collection. This will generate a preview with an id which can be used for the preview view.
-// ///
-// Task> Add(TEntity model) where TEntity : ZeroEntity;
-
-// ///
-// /// Updates an entity in the preview collection. This will generate a preview with an id which can be used for the preview view.
-// ///
-// Task> Update(string id, TEntity model) where TEntity : ZeroEntity;
-
-// ///
-// /// Get preview entity by Id
-// ///
-// Task GetById(string id);
-// }
-//}
diff --git a/zero.sln b/zero.sln
index 57a79e97..9aaf0b4b 100644
--- a/zero.sln
+++ b/zero.sln
@@ -13,6 +13,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "plugins", "plugins", "{6FAA
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "zero.Core", "zero.Core\zero.Core.csproj", "{CED2A7FA-8D35-4A2A-AF57-8B95307547CB}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "zero", "zero\zero.csproj", "{33CD6E46-CD81-42C3-9019-C0EAD557EE99}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "legacy", "legacy", "{93A1CCEB-A323-445E-8116-4575C5939B46}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -35,6 +39,10 @@ Global
{CED2A7FA-8D35-4A2A-AF57-8B95307547CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CED2A7FA-8D35-4A2A-AF57-8B95307547CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CED2A7FA-8D35-4A2A-AF57-8B95307547CB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {33CD6E46-CD81-42C3-9019-C0EAD557EE99}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {33CD6E46-CD81-42C3-9019-C0EAD557EE99}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {33CD6E46-CD81-42C3-9019-C0EAD557EE99}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {33CD6E46-CD81-42C3-9019-C0EAD557EE99}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -43,6 +51,7 @@ Global
{63A8AD15-15F0-4555-BD62-C38B4465FFC1} = {6FAA92A8-CA1A-48EA-9857-C9092971B4D1}
{C23CF90A-DB90-427F-816C-0E2FE20E29D0} = {6FAA92A8-CA1A-48EA-9857-C9092971B4D1}
{A8E5F34F-5400-4F92-96C6-7F91645BC220} = {6FAA92A8-CA1A-48EA-9857-C9092971B4D1}
+ {CED2A7FA-8D35-4A2A-AF57-8B95307547CB} = {93A1CCEB-A323-445E-8116-4575C5939B46}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {AD3F31B9-8BBB-4334-9571-F556B9E632C9}
diff --git a/zero/ApplicationBuilderExtensions.cs b/zero/ApplicationBuilderExtensions.cs
new file mode 100644
index 00000000..748ecebd
--- /dev/null
+++ b/zero/ApplicationBuilderExtensions.cs
@@ -0,0 +1,8 @@
+using Microsoft.AspNetCore.Builder;
+
+namespace zero;
+
+public static class ApplicationBuilderExtensions
+{
+ public static IZeroApplicationBuilder UseZero(this IApplicationBuilder app) => new ZeroApplicationBuilder(app);
+}
diff --git a/zero/Architecture/AssemblyDiscovery/AssemblyDiscovery.cs b/zero/Architecture/AssemblyDiscovery/AssemblyDiscovery.cs
new file mode 100644
index 00000000..c1ffceaf
--- /dev/null
+++ b/zero/Architecture/AssemblyDiscovery/AssemblyDiscovery.cs
@@ -0,0 +1,161 @@
+using Microsoft.AspNetCore.Mvc.ApplicationParts;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyModel;
+using System.Reflection;
+
+namespace zero.Architecture;
+
+public class AssemblyDiscovery : IAssemblyDiscovery
+{
+ public static IAssemblyDiscovery Current;
+
+ AssemblyDiscoveryContext Context;
+
+ IMvcBuilder Mvc;
+
+
+
+ public AssemblyDiscovery(IMvcBuilder mvc)
+ {
+ Mvc = mvc;
+ Context = new AssemblyDiscoveryContext();
+ Current = this;
+ }
+
+
+ ///
+ public void Execute(IEnumerable rules)
+ {
+ List assemblies = new List();
+ DependencyContext dependencyContext = DependencyContext.Load(Context.EntryAssembly);
+
+ if (dependencyContext == null)
+ {
+ return;
+ }
+
+ string[] existingLibs = Mvc.PartManager.ApplicationParts.OfType().Select(p => p.Name).ToArray();
+
+ IEnumerable libraries = dependencyContext.RuntimeLibraries.Where(lib => !existingLibs.Contains(lib.Name, StringComparer.OrdinalIgnoreCase));
+
+ foreach (RuntimeLibrary library in libraries)
+ {
+ if (rules.Any(rule => rule.IsValid(library, Context)))
+ {
+ IEnumerable libraryAssemblies = library.GetDefaultAssemblyNames(dependencyContext).Select(name => Assembly.Load(name));
+
+ foreach (Assembly assembly in libraryAssemblies)
+ {
+ Mvc.AddApplicationPart(assembly);
+ }
+ }
+ }
+ }
+
+
+ ///
+ public void AddAssembly(Assembly assembly)
+ {
+ string[] existingLibs = Mvc.PartManager.ApplicationParts.OfType().Select(p => p.Name).ToArray();
+
+ if (!existingLibs.Contains(assembly.GetName().Name))
+ {
+ Mvc.AddApplicationPart(assembly);
+ }
+ }
+
+
+ ///
+ public IEnumerable GetTypes(bool allowGenerics = false) => GetTypes(typeof(TService), allowGenerics);
+
+ ///
+ public IEnumerable GetTypes(IEnumerable parts, bool allowGenerics = false) => GetTypes(typeof(TService), parts, allowGenerics);
+
+ ///
+ public IEnumerable GetTypes(Type serviceType, bool allowGenerics = false) => GetAllClassTypes(allowGenerics).Where(t => serviceType.GetTypeInfo().IsAssignableFrom(t) && t.AsType() != serviceType);
+
+ ///
+ public IEnumerable GetTypes(Type serviceType, IEnumerable parts, bool allowGenerics = false) => GetAllClassTypes(parts, allowGenerics).Where(t => serviceType.GetTypeInfo().IsAssignableFrom(t) && t.AsType() != serviceType);
+
+ ///
+ public IEnumerable GetAllClassTypes(bool allowGenerics = false) => GetAllTypes().Where(t => t.IsClass && !t.IsAbstract && (allowGenerics || !t.ContainsGenericParameters));
+
+ ///
+ public IEnumerable GetAllClassTypes(IEnumerable parts, bool allowGenerics = false) => GetAllTypes(parts).Where(t => t.IsClass && !t.IsAbstract && (allowGenerics || !t.ContainsGenericParameters));
+
+ ///
+ public IEnumerable GetAssemblies() => Mvc.PartManager.ApplicationParts.OfType().Select(p => p.Assembly);
+
+ ///
+ public IEnumerable GetAssemblies(IEnumerable parts) => parts.OfType().Select(p => p.Assembly);
+
+ ///
+ public IEnumerable GetAllTypes() => Mvc.PartManager.ApplicationParts.OfType().SelectMany(p => p.Types);
+
+ ///
+ public IEnumerable GetAllTypes(IEnumerable parts) => parts.OfType().SelectMany(p => p.Types);
+}
+
+
+public interface IAssemblyDiscovery
+{
+ ///
+ /// Discovers runtime assemblies based on the given rules
+ ///
+ void Execute(IEnumerable rules);
+
+ ///
+ /// Manually add an assembly
+ ///
+ void AddAssembly(Assembly assembly);
+
+ ///
+ /// Get all discovered types which implement a certain service
+ ///
+ IEnumerable GetTypes(bool allowGenerics = false);
+
+ ///
+ /// Get all discovered types which implement a certain service
+ ///
+ IEnumerable GetTypes(IEnumerable parts, bool allowGenerics = false);
+
+ ///
+ /// Get all discovered types which implement a certain service
+ ///
+ IEnumerable GetTypes(Type serviceType, bool allowGenerics = false);
+
+ ///
+ /// Get all discovered types which implement a certain service
+ ///
+ IEnumerable GetTypes(Type serviceType, IEnumerable parts, bool allowGenerics = false);
+
+ ///
+ /// Get all registered types
+ ///
+ IEnumerable GetAllTypes();
+
+ ///
+ /// Get all registered types
+ ///
+ IEnumerable GetAllTypes(IEnumerable parts);
+
+ ///
+ /// Get all registered types
+ ///
+ IEnumerable GetAllClassTypes(bool allowGenerics = false);
+
+ ///
+ /// Get all registered types
+ ///
+ IEnumerable GetAllClassTypes(IEnumerable parts, bool allowGenerics = false);
+
+ ///
+ /// Get all registered assemblies
+ ///
+ IEnumerable GetAssemblies();
+
+ ///
+ /// Get all registered assemblies
+ ///
+ IEnumerable GetAssemblies(IEnumerable parts);
+}
diff --git a/zero/Architecture/AssemblyDiscovery/AssemblyDiscoveryContext.cs b/zero/Architecture/AssemblyDiscovery/AssemblyDiscoveryContext.cs
new file mode 100644
index 00000000..33cd0836
--- /dev/null
+++ b/zero/Architecture/AssemblyDiscovery/AssemblyDiscoveryContext.cs
@@ -0,0 +1,26 @@
+using System.Reflection;
+
+namespace zero.Architecture;
+
+public class AssemblyDiscoveryContext
+{
+ public Assembly EntryAssembly { get; private set; }
+
+ public string EntryAssemblyName { get; private set; }
+
+ public bool HasEntryAssembly => EntryAssembly != null;
+
+
+ public AssemblyDiscoveryContext()
+ {
+ EntryAssembly = Assembly.GetEntryAssembly();
+ EntryAssemblyName = EntryAssembly?.GetName().Name;
+ }
+
+
+ public AssemblyDiscoveryContext(Assembly entryAssembly)
+ {
+ EntryAssembly = entryAssembly;
+ EntryAssemblyName = entryAssembly.GetName().Name;
+ }
+}
diff --git a/zero/Architecture/AssemblyDiscovery/IAssemblyDiscoveryRule.cs b/zero/Architecture/AssemblyDiscovery/IAssemblyDiscoveryRule.cs
new file mode 100644
index 00000000..d2f4a5ab
--- /dev/null
+++ b/zero/Architecture/AssemblyDiscovery/IAssemblyDiscoveryRule.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.DependencyModel;
+
+namespace zero.Architecture;
+
+public interface IAssemblyDiscoveryRule
+{
+ ///
+ /// Returns true if the specified runtime library should be added to
+ /// the ApplicationPartManager; otherwise false.
+ ///
+ bool IsValid(RuntimeLibrary library, AssemblyDiscoveryContext context);
+}
\ No newline at end of file
diff --git a/zero/Architecture/AssemblyDiscovery/ZeroAssemblyDiscoveryRule.cs b/zero/Architecture/AssemblyDiscovery/ZeroAssemblyDiscoveryRule.cs
new file mode 100644
index 00000000..be1f7ff7
--- /dev/null
+++ b/zero/Architecture/AssemblyDiscovery/ZeroAssemblyDiscoveryRule.cs
@@ -0,0 +1,16 @@
+using Microsoft.Extensions.DependencyModel;
+
+namespace zero.Architecture;
+
+public class ZeroAssemblyDiscoveryRule : IAssemblyDiscoveryRule
+{
+ const string ZERO_PREFIX = "zero.";
+
+ ///
+ public bool IsValid(RuntimeLibrary library, AssemblyDiscoveryContext context)
+ {
+ StringComparison casing = StringComparison.OrdinalIgnoreCase;
+ // TODO we need to auto-add assemblies and discover their types which have implementations of IZeroPlugin
+ return library.Name.StartsWith(ZERO_PREFIX, casing) || (context.HasEntryAssembly && library.Name.Contains(context.EntryAssemblyName, casing));
+ }
+}
\ No newline at end of file
diff --git a/zero/Architecture/ZeroModule.cs b/zero/Architecture/ZeroModule.cs
new file mode 100644
index 00000000..959cdea8
--- /dev/null
+++ b/zero/Architecture/ZeroModule.cs
@@ -0,0 +1,50 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace zero.Architecture;
+
+public abstract class ZeroModule : IZeroModule
+{
+ ///
+ public virtual int Order { get; } = 0;
+
+ ///
+ public virtual int ConfigureOrder => Order;
+
+ ///
+ public virtual void ConfigureServices(IServiceCollection services, IConfiguration configuration) { }
+
+ ///
+ public virtual void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) { }
+}
+
+
+public interface IZeroModule
+{
+ ///
+ /// Get the value to use to order startups to configure services. The default is 0.
+ ///
+ int Order { get; }
+
+ ///
+ /// Get the value to use to order startups to build the pipeline. The default is the 'Order' property.
+ ///
+ int ConfigureOrder { get; }
+
+ ///
+ /// This method gets called by the runtime. Use this method to add services to the container.
+ /// For more information on how to configure your application, visit http://go.microsoft.com/fwlink/?LinkID=398940
+ ///
+ /// The collection of service descriptors.
+ void ConfigureServices(IServiceCollection services, IConfiguration configuration);
+
+ ///
+ /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
+ ///
+ ///
+ ///
+ ///
+ void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider);
+}
\ No newline at end of file
diff --git a/zero/Architecture/ZeroModuleCollection.cs b/zero/Architecture/ZeroModuleCollection.cs
new file mode 100644
index 00000000..8ae3278b
--- /dev/null
+++ b/zero/Architecture/ZeroModuleCollection.cs
@@ -0,0 +1,70 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using System.Collections.Concurrent;
+
+namespace zero.Architecture;
+
+public class ZeroModuleCollection : ZeroModule
+{
+ ConcurrentDictionary _modules = new();
+
+
+ ///
+ /// Get all registered modules
+ ///
+ public IEnumerable GetAll() => _modules.Values;
+
+
+ ///
+ /// Adds a zero module
+ ///
+ public void Add() where T : class, IZeroModule, new()
+ {
+ Add(new T());
+ }
+
+
+ ///
+ /// Adds a zero module
+ ///
+ public void Add(T moduleInstance) where T : IZeroModule
+ {
+ Add(typeof(T), moduleInstance);
+ }
+
+
+ ///
+ /// Adds a zero module
+ ///
+ public void Add(Type moduleType, IZeroModule moduleInstance)
+ {
+ if (_modules.ContainsKey(moduleType))
+ {
+ return;
+ }
+
+ _modules.TryAdd(moduleType, moduleInstance);
+ }
+
+
+ ///
+ public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
+ {
+ foreach (var module in _modules.OrderBy(x => x.Value.Order))
+ {
+ module.Value.ConfigureServices(services, configuration);
+ }
+ }
+
+
+ ///
+ public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider)
+ {
+ foreach (var module in _modules.OrderBy(x => x.Value.ConfigureOrder))
+ {
+ module.Value.Configure(app, routes, serviceProvider);
+ }
+ }
+}
\ No newline at end of file
diff --git a/zero/Communication/Handlers/IHandler.cs b/zero/Communication/Handlers/IHandler.cs
new file mode 100644
index 00000000..c2ea5d02
--- /dev/null
+++ b/zero/Communication/Handlers/IHandler.cs
@@ -0,0 +1,5 @@
+namespace zero.Communication;
+
+public interface IHandler
+{
+}
diff --git a/zero/Communication/Handlers/IHandlerHolder.cs b/zero/Communication/Handlers/IHandlerHolder.cs
new file mode 100644
index 00000000..096c88ef
--- /dev/null
+++ b/zero/Communication/Handlers/IHandlerHolder.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace zero.Communication;
+
+public class HandlerHolder : IHandlerHolder
+{
+ IServiceProvider Services { get; }
+
+ public HandlerHolder(IServiceProvider services)
+ {
+ Services = services;
+ }
+
+
+ public T Get() where T : IHandler
+ {
+ return Services.GetService();
+ }
+
+
+ public IEnumerable GetAll() where T : IHandler
+ {
+ return Services.GetService>();
+ }
+}
+
+public interface IHandlerHolder
+{
+ T Get() where T : IHandler;
+
+ IEnumerable GetAll() where T : IHandler;
+}
\ No newline at end of file
diff --git a/zero/Communication/Interceptors/Interceptor.cs b/zero/Communication/Interceptors/Interceptor.cs
new file mode 100644
index 00000000..f47498c3
--- /dev/null
+++ b/zero/Communication/Interceptors/Interceptor.cs
@@ -0,0 +1,207 @@
+namespace zero.Communication;
+
+
+public abstract partial class Interceptor : Interceptor, IInterceptor where T : ZeroIdEntity
+{
+ ///
+ public Type ModelType { get; protected set; }
+
+
+ public Interceptor()
+ {
+ Gravity = 0;
+ ModelType = typeof(T);
+ Name = ModelType.Name;
+ }
+
+
+ ///
+ public override bool CanHandle(InterceptorParameters args, Type modelType) => ModelType.IsAssignableFrom(modelType);
+
+ ///
+ public virtual Task> Creating(InterceptorParameters args, T model) => Task.FromResult>(default);
+
+ ///
+ public sealed override async Task> Creating(InterceptorParameters args, ZeroIdEntity model) => Convert(await Creating(args, model as T));
+
+ ///
+ public virtual Task> Updating(InterceptorParameters args, T model) => Task.FromResult>(default);
+
+ ///
+ public sealed override async Task> Updating(InterceptorParameters args, ZeroIdEntity model) => Convert(await Updating(args, model as T));
+
+ ///
+ public virtual Task> Deleting(InterceptorParameters args, T model) => Task.FromResult>(default);
+
+ ///
+ public sealed override async Task> Deleting(InterceptorParameters args, ZeroIdEntity model) => Convert(await Deleting(args, model as T));
+
+ ///
+ public virtual Task Created(InterceptorParameters args, T model) => Task.CompletedTask;
+
+ ///
+ public sealed override Task Created(InterceptorParameters args, ZeroIdEntity model) => Created(args, model as T);
+
+ ///
+ public virtual Task Updated(InterceptorParameters args, T model) => Task.CompletedTask;
+
+ ///
+ public sealed override Task Updated(InterceptorParameters args, ZeroIdEntity model) => Updated(args, model as T);
+
+ ///
+ public virtual Task Deleted(InterceptorParameters args, T model) => Task.CompletedTask;
+
+ ///
+ public sealed override Task Deleted(InterceptorParameters args, ZeroIdEntity model) => Deleted(args, model as T);
+
+
+ InterceptorResult Convert(InterceptorResult result)
+ {
+ if (result == default)
+ {
+ return default;
+ }
+
+ return new InterceptorResult()
+ {
+ Continue = result.Continue,
+ InterceptorHash = result.InterceptorHash,
+ Result = result.Result != null ? result.Result.ConvertTo(result.Result.Model) : null
+ };
+ }
+}
+
+
+public abstract partial class Interceptor : IInterceptor
+{
+ ///
+ public int Gravity { get; protected set; }
+
+ ///
+ public string Name { get; protected set; }
+
+ ///
+ public string Hash { get; protected set; }
+
+
+ public Interceptor()
+ {
+ Gravity = 0;
+ Hash = IdGenerator.Create();
+ }
+
+
+ ///
+ public virtual bool CanHandle(InterceptorParameters args, Type modelType) => true;
+
+ ///
+ public virtual Task> Creating(InterceptorParameters args, ZeroIdEntity model) => Task.FromResult>(default);
+
+ ///
+ public virtual Task> Updating(InterceptorParameters args, ZeroIdEntity model) => Task.FromResult>(default);
+
+ ///
+ public virtual Task> Deleting(InterceptorParameters args, ZeroIdEntity model) => Task.FromResult>(default);
+
+ ///
+ public virtual Task Created(InterceptorParameters args, ZeroIdEntity model) => Task.CompletedTask;
+
+ ///
+ public virtual Task Updated(InterceptorParameters args, ZeroIdEntity model) => Task.CompletedTask;
+
+ ///
+ public virtual Task Deleted(InterceptorParameters args, ZeroIdEntity model) => Task.CompletedTask;
+}
+
+
+public interface IInterceptor : IInterceptor where T : ZeroIdEntity
+{
+ ///
+ /// Type of the associated model
+ ///
+ Type ModelType { get; }
+
+ ///
+ /// Called after an entity has been stored but before the session has saved its changes
+ ///
+ Task Created(InterceptorParameters args, T model);
+
+ ///
+ /// Called before an entity is stored and validated
+ ///
+ Task> Creating(InterceptorParameters args, T model);
+
+ ///
+ /// Called after an entity has been deleted but before the session has saved its changes
+ ///
+ Task Deleted(InterceptorParameters args, T model);
+
+ ///
+ /// Called before an entity is deleted
+ ///
+ Task> Deleting(InterceptorParameters args, T model);
+
+ ///
+ /// Called after an entity has been updated but before the session has saved its changes
+ ///
+ Task Updated(InterceptorParameters args, T model);
+
+ ///
+ /// Called before an entity is stored and validated
+ ///
+ Task> Updating(InterceptorParameters args, T model);
+}
+
+
+public interface IInterceptor
+{
+ ///
+ /// Interceptors with a higher gravity will run before any with lower gravity
+ ///
+ int Gravity { get; }
+
+ ///
+ /// Readable name for this interceptor
+ ///
+ string Name { get; }
+
+ ///
+ /// Hash which is used for this interceptor to be able to pass results from Start to Complete methods
+ ///
+ string Hash { get; }
+
+ ///
+ /// Whether any of the interceptor methods is allowed to run based on the parameters
+ ///
+ bool CanHandle(InterceptorParameters args, Type modelType);
+
+ ///
+ /// Called after an entity has been stored but before the session has saved its changes
+ ///
+ Task Created(InterceptorParameters args, ZeroIdEntity model);
+
+ ///
+ /// Called before an entity is stored and validated
+ ///
+ Task> Creating(InterceptorParameters args, ZeroIdEntity model);
+
+ ///
+ /// Called after an entity has been deleted but before the session has saved its changes
+ ///
+ Task Deleted(InterceptorParameters args, ZeroIdEntity model);
+
+ ///
+ /// Called before an entity is deleted
+ ///
+ Task> Deleting(InterceptorParameters args, ZeroIdEntity model);
+
+ ///
+ /// Called after an entity has been updated but before the session has saved its changes
+ ///
+ Task Updated(InterceptorParameters args, ZeroIdEntity model);
+
+ ///
+ /// Called before an entity is stored and validated
+ ///
+ Task> Updating(InterceptorParameters args, ZeroIdEntity model);
+}
\ No newline at end of file
diff --git a/zero/Communication/Interceptors/InterceptorInstruction.cs b/zero/Communication/Interceptors/InterceptorInstruction.cs
new file mode 100644
index 00000000..9b88512d
--- /dev/null
+++ b/zero/Communication/Interceptors/InterceptorInstruction.cs
@@ -0,0 +1,148 @@
+using Microsoft.Extensions.Logging;
+
+namespace zero.Communication;
+
+public class InterceptorInstruction where T : ZeroIdEntity, new()
+{
+ public Guid Guid { get; private set; }
+
+ public InterceptorRunType Runtype { get; private set; }
+
+ public T Model { get; private set; }
+
+ public T PreviousModel { get; private set; }
+
+ public Type ModelType { get; private set; }
+
+ public Result Result { get; private set; }
+
+ protected IZeroContext Context { get; private set; }
+
+ protected Lazy> Interceptors { get; private set; }
+
+ protected Dictionary InterceptorCache { get; private set; } = new();
+
+ protected IInterceptors InterceptorHandler { get; private set; }
+
+ protected ILogger Logger { get; private set; }
+
+ protected Func InterceptorFilter { get; private set; } = x => true;
+
+
+ internal InterceptorInstruction(IInterceptors interceptors, IZeroContext context, Lazy> registrations, ILogger logger, InterceptorRunType runtype, T model, T previousModel = null)
+ {
+ InterceptorHandler = interceptors;
+ Context = context;
+ Guid = Guid.NewGuid();
+ Logger = logger;
+ Runtype = runtype;
+ Model = model;
+ PreviousModel = previousModel;
+ Interceptors = registrations;
+
+ ModelType = model.GetType();
+ }
+
+
+ ///
+ /// Custom interceptor filter for this instruction (the CanHandle() method for each interceptor is still activated)
+ ///
+ public void Filter(Func predicate)
+ {
+ InterceptorFilter = predicate;
+ }
+
+
+ ///
+ /// Run all interceptors (in order) which can handle the given type.
+ /// Depending on the action any of the following methods on the interceptor is called: Creating(), Updating(), Deleting().
+ /// If any of the interceptors returns a result the operation is cancelled and this result is returned.
+ ///
+ public async Task Start(IStoreOperations operations)
+ {
+ foreach (IInterceptor interceptor in GetInterceptors())
+ {
+ InterceptorParameters parameters = new()
+ {
+ Context = Context,
+ Store = Context.Store,
+ Properties = new(),
+ Interceptors = InterceptorHandler,
+ Operations = operations,
+ PreviousModel = PreviousModel
+ };
+
+ if (!interceptor.CanHandle(parameters, ModelType))
+ {
+ continue;
+ }
+
+
+ Logger.LogDebug("Run interceptor {interceptor} for {type}:{operation}", interceptor.Name, ModelType, Runtype);
+
+ InterceptorResult result = (await HandleBefore(interceptor, parameters)) ?? new();
+ result.InterceptorHash = IdGenerator.Create(32);
+
+ InterceptorCache.Add(interceptor, parameters);
+
+ // we cancel all further interceptors if a result is available and return this instead
+ if (result.Result != null)
+ {
+ Result = result.Result.ConvertTo(result.Result.Model as T);
+ return false;
+ }
+
+ // the Continue task will cancel all further interceptors
+ if (!result.Continue)
+ {
+ break;
+ }
+ }
+
+ return true;
+ }
+
+
+ ///
+ /// Run all interceptors (in order) which can handle the given type.
+ /// Depending on the action any of the following methods on the interceptor is called: Created(), Updated(), Deleted().
+ /// The parameters which are returned from the Start() operation are passed to the methods.
+ ///
+ public async Task Complete()
+ {
+ foreach ((IInterceptor interceptor, InterceptorParameters parameters) in InterceptorCache)
+ {
+ await HandleAfter(interceptor, parameters);
+ }
+ }
+
+
+ protected IEnumerable GetInterceptors()
+ {
+ return Interceptors.Value.Where(InterceptorFilter).OrderByDescending(x => x.Gravity);
+ }
+
+
+ ///
+ /// Proxy for handling methods on an interceptor
+ ///
+ protected Task> HandleBefore(IInterceptor interceptor, InterceptorParameters parameters) => Runtype switch
+ {
+ InterceptorRunType.Create => interceptor.Creating(parameters, Model),
+ InterceptorRunType.Update => interceptor.Updating(parameters, Model),
+ InterceptorRunType.Delete => interceptor.Deleting(parameters, Model),
+ _ => throw new NotImplementedException()
+ };
+
+
+ ///
+ /// Proxy for handling methods on an interceptor
+ ///
+ protected Task HandleAfter(IInterceptor interceptor, InterceptorParameters parameters) => Runtype switch
+ {
+ InterceptorRunType.Create => interceptor.Created(parameters, Model),
+ InterceptorRunType.Update => interceptor.Updated(parameters, Model),
+ InterceptorRunType.Delete => interceptor.Deleted(parameters, Model),
+ _ => throw new NotImplementedException()
+ };
+}
\ No newline at end of file
diff --git a/zero/Communication/Interceptors/InterceptorParameters.cs b/zero/Communication/Interceptors/InterceptorParameters.cs
new file mode 100644
index 00000000..2e36a170
--- /dev/null
+++ b/zero/Communication/Interceptors/InterceptorParameters.cs
@@ -0,0 +1,44 @@
+namespace zero.Communication;
+
+public class InterceptorParameters
+{
+ ///
+ /// The current zero context
+ ///
+ public IZeroContext Context { get; set; }
+
+ ///
+ /// Raven document store
+ ///
+ public IZeroStore Store { get; set; }
+
+ ///
+ /// Access to other interceptor methods
+ ///
+ public IInterceptors Interceptors { get; internal set; }
+
+ ///
+ /// Access to operations
+ ///
+ public IStoreOperations Operations { get; internal set; }
+
+ ///
+ /// Parameters from the interceptor which ran on before the operation (only available for completed operations)
+ ///
+ public Dictionary Properties { get; set; } = new();
+
+ ///
+ /// Get a typed property
+ ///
+ public TProp Property(string key) => Properties.GetValueOrDefault(key);
+
+ ///
+ /// Get a typed property
+ ///
+ public bool TryGetProperty(string key, out TProp property) => Properties.TryGetValue(key, out property);
+
+ ///
+ /// Holds a reference to the previously existing model when an update happens (can be null)
+ ///
+ public object PreviousModel { get; set; }
+}
\ No newline at end of file
diff --git a/zero/Communication/Interceptors/InterceptorResult.cs b/zero/Communication/Interceptors/InterceptorResult.cs
new file mode 100644
index 00000000..735ae3ba
--- /dev/null
+++ b/zero/Communication/Interceptors/InterceptorResult.cs
@@ -0,0 +1,19 @@
+namespace zero.Communication;
+
+public class InterceptorResult
+{
+ ///
+ /// Autoset. Hash used to match interceptors in order to correctly pass parameters
+ ///
+ internal string InterceptorHash { get; set; }
+
+ ///
+ /// Prevent further interceptors from running for this operation (only valid for the current interception type, e.g. Update/Created/Purge/...)
+ ///
+ public bool Continue { get; set; } = true;
+
+ ///
+ /// Set a result. This will prevent further execution of the operation and cancels all subsequent interceptors
+ ///
+ public Result Result { get; set; }
+}
\ No newline at end of file
diff --git a/zero/Communication/Interceptors/InterceptorRunType.cs b/zero/Communication/Interceptors/InterceptorRunType.cs
new file mode 100644
index 00000000..82be301e
--- /dev/null
+++ b/zero/Communication/Interceptors/InterceptorRunType.cs
@@ -0,0 +1,8 @@
+namespace zero.Communication;
+
+public enum InterceptorRunType
+{
+ Create = 1,
+ Update = 2,
+ Delete = 3
+}
\ No newline at end of file
diff --git a/zero/Communication/Interceptors/Interceptors.cs b/zero/Communication/Interceptors/Interceptors.cs
new file mode 100644
index 00000000..297f1676
--- /dev/null
+++ b/zero/Communication/Interceptors/Interceptors.cs
@@ -0,0 +1,49 @@
+using Microsoft.Extensions.Logging;
+
+namespace zero.Communication;
+
+public class Interceptors : IInterceptors
+{
+ protected IZeroContext Context { get; set; }
+
+ protected Lazy> Registrations { get; set; }
+
+ protected ILogger Logger { get; set; }
+
+
+ public Interceptors(IZeroContext context, Lazy> registrations, ILogger logger)
+ {
+ Context = context;
+ Registrations = registrations;
+ Logger = logger;
+ }
+
+
+ ///
+ public InterceptorInstruction ForCreate(T model) where T : ZeroIdEntity, new() => new(this, Context, Registrations, Logger, InterceptorRunType.Create, model);
+
+ ///
+ public InterceptorInstruction ForUpdate(T model, T previousModel = null) where T : ZeroIdEntity, new() => new(this, Context, Registrations, Logger, InterceptorRunType.Update, model, previousModel);
+
+ ///
+ public InterceptorInstruction ForDelete(T model) where T : ZeroIdEntity, new() => new(this, Context, Registrations, Logger, InterceptorRunType.Delete, model);
+}
+
+
+public interface IInterceptors
+{
+ ///
+ /// Instruction which can run interceptors before and after a creating an entity
+ ///
+ InterceptorInstruction ForCreate(T model) where T : ZeroIdEntity, new();
+
+ ///
+ /// Instruction which can run interceptors before and after updating an entity
+ ///
+ InterceptorInstruction ForUpdate(T model, T previousModel = null) where T : ZeroIdEntity, new();
+
+ ///
+ /// Instruction which can run interceptors before and after deleting an entity
+ ///
+ InterceptorInstruction ForDelete(T model) where T : ZeroIdEntity, new();
+}
\ No newline at end of file
diff --git a/zero/Communication/LazilyResolved.cs b/zero/Communication/LazilyResolved.cs
new file mode 100644
index 00000000..0666dae4
--- /dev/null
+++ b/zero/Communication/LazilyResolved.cs
@@ -0,0 +1,11 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace zero.Communication;
+
+public class LazilyResolved : Lazy
+{
+ public LazilyResolved(IServiceProvider serviceProvider): base(serviceProvider.GetRequiredService)
+ {
+
+ }
+}
\ No newline at end of file
diff --git a/zero/Communication/Messages/IMessage.cs b/zero/Communication/Messages/IMessage.cs
new file mode 100644
index 00000000..4c03d107
--- /dev/null
+++ b/zero/Communication/Messages/IMessage.cs
@@ -0,0 +1,6 @@
+
+namespace zero.Communication;
+
+public interface IMessage
+{
+}
diff --git a/zero/Communication/Messages/IMessageHandler.cs b/zero/Communication/Messages/IMessageHandler.cs
new file mode 100644
index 00000000..a818e1de
--- /dev/null
+++ b/zero/Communication/Messages/IMessageHandler.cs
@@ -0,0 +1,26 @@
+namespace zero.Communication;
+
+///
+/// Indicates a handler that can perform an action when a message of
+/// a certain type is received. (could be an interface rather than concrete type)
+///
+public interface IMessageHandler where TMessage : IMessage
+{
+ ///
+ /// Method to invoke when a message of type TMessage is published
+ ///
+ Task Handle(TMessage message);
+}
+
+
+///
+/// Indicates a handler that can perform an action when a message of
+/// a certain type is received. (could be an interface rather than concrete type)
+///
+public interface IBatchMessageHandler : IMessageHandler where TMessage : IMessage
+{
+ ///
+ /// Method to invoke when a batch of messages of type TMessage are published
+ ///
+ Task HandleBatchAsync(IReadOnlyCollection message);
+}
diff --git a/zero/Communication/Messages/MessageAggregator.cs b/zero/Communication/Messages/MessageAggregator.cs
new file mode 100644
index 00000000..cd6a3f37
--- /dev/null
+++ b/zero/Communication/Messages/MessageAggregator.cs
@@ -0,0 +1,52 @@
+using System.Collections.Concurrent;
+
+namespace zero.Communication;
+
+public class MessageAggregator : IMessageAggregator
+{
+ readonly ConcurrentBag Subscription = new ConcurrentBag();
+ readonly IServiceProvider ServiceProvider;
+
+
+ public MessageAggregator(IServiceProvider serviceProvider)
+ {
+ ServiceProvider = serviceProvider;
+ }
+
+
+ ///
+ public async Task Publish(TMessage message) where TMessage : class, IMessage
+ {
+ IEnumerable subscriptions = Subscription.Where(s => s.CanDeliver());
+
+ foreach (IMessageSubscription subscription in subscriptions)
+ {
+ await subscription.Deliver(ServiceProvider, message);
+ }
+ }
+
+
+ ///
+ public void Activate()
+ where TMessage : class, IMessage
+ where TMessageHandler : IMessageHandler
+ {
+ Subscription.Add(new MessageSubscription());
+ }
+}
+
+
+public interface IMessageAggregator
+{
+ ///
+ /// Publishes the specified message, invoking any handlers subscribed to the message.
+ ///
+ Task Publish(TMessage message) where TMessage : class, IMessage;
+
+ ///
+ /// Subscribes the specified handler to the spified message type
+ ///
+ void Activate()
+ where TMessage : class, IMessage
+ where TMessageHandler : IMessageHandler;
+}
diff --git a/zero/Communication/Messages/MessageSubscription.cs b/zero/Communication/Messages/MessageSubscription.cs
new file mode 100644
index 00000000..3698f175
--- /dev/null
+++ b/zero/Communication/Messages/MessageSubscription.cs
@@ -0,0 +1,41 @@
+using Microsoft.Extensions.DependencyInjection;
+using System.Reflection;
+
+namespace zero.Communication;
+
+internal class MessageSubscription : IMessageSubscription
+ where TMessage : class, IMessage
+ where TMessageHandler : IMessageHandler
+{
+ public bool CanDeliver()
+ {
+ return typeof(TMessage).GetTypeInfo().IsAssignableFrom(typeof(T));
+ }
+
+ public async Task Deliver(IServiceProvider serviceProvider, object message)
+ {
+ if (message == null)
+ {
+ throw new ArgumentNullException(nameof(message));
+ }
+ if (!(message is TMessage))
+ {
+ throw new ArgumentException($"{ nameof(message) } must be of type '{typeof(TMessage).FullName}'");
+ }
+
+ var handlers = serviceProvider.GetServices();
+
+ foreach (var handler in handlers)
+ {
+ await handler.Handle((TMessage)message);
+ }
+ }
+}
+
+
+public interface IMessageSubscription
+{
+ bool CanDeliver();
+
+ Task Deliver(IServiceProvider serviceProvider, object message);
+}
diff --git a/zero/Communication/ZeroCommunicationModule.cs b/zero/Communication/ZeroCommunicationModule.cs
new file mode 100644
index 00000000..bd3a05c4
--- /dev/null
+++ b/zero/Communication/ZeroCommunicationModule.cs
@@ -0,0 +1,15 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace zero.Communication;
+
+internal class ZeroCommunicationModule : ZeroModule
+{
+ public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddScoped();
+ services.AddScoped();
+ services.AddTransient();
+ services.AddTransient(typeof(Lazy<>), typeof(LazilyResolved<>));
+ }
+}
\ No newline at end of file
diff --git a/zero/Configuration/Constants.cs b/zero/Configuration/Constants.cs
new file mode 100644
index 00000000..fa96aa14
--- /dev/null
+++ b/zero/Configuration/Constants.cs
@@ -0,0 +1,102 @@
+namespace zero.Configuration;
+
+public static partial class Constants
+{
+ public const string ErrorFieldNone = "__zero_no_field";
+
+ public static partial class Tabs
+ {
+ public const string General = "general";
+ }
+
+ public static partial class Auth
+ {
+ public const string SystemUser = "system";
+ public const string DefaultScheme = "zeroScheme";
+ public const string BackofficeDisplayName = "Zero Backoffice";
+ public const string BackofficeScheme = "zeroBackoffice";
+ public const string BackofficeCookieName = "zero.be.session";
+ public const string DefaultCookieName = "zero.session";
+
+ public static partial class Claims
+ {
+ public const string IsZero = "zero.claim.iszero";
+ public const string IsSuper = "zero.claim.issuper";
+ public const string UserId = "zero.claim.userid";
+ public const string UserName = "zero.claim.username";
+ public const string Role = "zero.claim.rolealias";
+ public const string SecurityStamp = "zero.claim.securitystamp";
+ public const string Email = "zero.claim.email";
+ public const string Permission = "zero.claim.permission";
+ public const string DefaultAppId = "zero.claim.defaultAppId";
+ public const string AppId = "zero.claim.appId";
+ public const string TicketExpires = "zero.claim.ticketExpires";
+ }
+ }
+
+
+ public static partial class Permissions
+ {
+ public static partial class Groups
+ {
+ public const string Sections = "zero.permissions.groups.sections";
+ public const string Spaces = "zero.permissions.groups.spaces";
+ public const string Settings = "zero.permissions.groups.settings";
+ public const string Pages = "zero.permissions.groups.pages";
+ public const string Modules = "zero.permissions.groups.modules";
+ }
+ }
+
+
+ public static partial class Database
+ {
+ public const string ReservationPrefix = "zero.";
+ public const string Expires = Raven.Client.Constants.Documents.Metadata.Expires;
+ }
+
+ public static partial class Sections
+ {
+ public const string Dashboard = "dashboard";
+ public const string Pages = "pages";
+ public const string Spaces = "spaces";
+ public const string Media = "media";
+ public const string Settings = "settings";
+ }
+
+ public static partial class Settings
+ {
+ public const string Updates = "updates";
+ public const string Applications = "applications";
+ public const string Users = "users";
+ public const string Languages = "languages";
+ public const string Translations = "translations";
+ public const string Countries = "countries";
+ public const string Mails = "mailTemplates";
+ public const string Integrations = "integrations";
+ public const string Logging = "logs";
+ public const string Plugins = "plugins";
+ public const string CreatePlugin = "createplugin";
+ }
+
+ public static partial class PermissionCollections
+ {
+ public const string Sections = "permissionCollectionSections";
+ public const string Spaces = "permissionCollectionSpaces";
+ public const string Settings = "permisssionCollectionSettings";
+ public const string Modules = "permissionCollectionModules";
+ }
+
+ public static partial class Pages
+ {
+ public const string FolderAlias = "zero.folder";
+ public const string DefaultRootPageTypeAlias = "root";
+ public const string PageRouteProviderAlias = "zero.pages";
+ }
+
+ public static partial class Routing
+ {
+ public const string InternalRoutePrefix = "/__zero/";
+
+ public const string ErrorRoute = InternalRoutePrefix + "error";
+ }
+}
diff --git a/zero/Configuration/IZeroCollectionOptions.cs b/zero/Configuration/IZeroCollectionOptions.cs
new file mode 100644
index 00000000..1bd70b9e
--- /dev/null
+++ b/zero/Configuration/IZeroCollectionOptions.cs
@@ -0,0 +1,6 @@
+namespace zero.Configuration;
+
+public interface IZeroCollectionOptions
+{
+
+}
\ No newline at end of file
diff --git a/zero/Configuration/OptionsType.cs b/zero/Configuration/OptionsType.cs
new file mode 100644
index 00000000..44d73a4a
--- /dev/null
+++ b/zero/Configuration/OptionsType.cs
@@ -0,0 +1,26 @@
+using FluentValidation;
+
+namespace zero.Configuration;
+
+public abstract class OptionsType
+{
+ ///
+ /// Type of the associated model class
+ ///
+ public Type ContentType { get; set; }
+
+ ///
+ /// The alias
+ ///
+ public string Alias { get; set; }
+
+ ///
+ /// The name of the options type
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// Set a validator for the editor
+ ///
+ public IValidator Validator { get; set; }
+}
\ No newline at end of file
diff --git a/zero/Configuration/ZeroConfigurationModule.cs b/zero/Configuration/ZeroConfigurationModule.cs
new file mode 100644
index 00000000..223c0047
--- /dev/null
+++ b/zero/Configuration/ZeroConfigurationModule.cs
@@ -0,0 +1,22 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace zero.Configuration;
+
+internal class ZeroConfigurationModule : ZeroModule
+{
+ public override int Order => -1000;
+
+ public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddOptions().Configure((opts, svc) =>
+ {
+ opts.ServiceProvider = svc;
+ opts.Version = "1.0.0-alpha.1";
+ opts.TokenExpiration = TimeSpan.FromHours(3);
+ }).Bind(configuration.GetSection("Zero"));
+
+ services.AddTransient(factory => factory.GetService>().Value);
+ }
+}
\ No newline at end of file
diff --git a/zero/Configuration/ZeroOptions.cs b/zero/Configuration/ZeroOptions.cs
new file mode 100644
index 00000000..19e0b611
--- /dev/null
+++ b/zero/Configuration/ZeroOptions.cs
@@ -0,0 +1,63 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using System.Collections.Concurrent;
+
+namespace zero.Configuration;
+
+public class ZeroOptions : IZeroOptions
+{
+ ///
+ public bool Initialized => !string.IsNullOrEmpty(For()?.Database);
+
+ ///
+ public string Version { get; set; }
+
+ ///
+ public TimeSpan TokenExpiration { get; set; }
+
+
+ internal IServiceProvider ServiceProvider { get; set; }
+
+ protected ConcurrentDictionary OptionsCache { get; private set; } = new();
+
+
+ ///
+ public TOptions For() where TOptions : class
+ {
+ Type type = typeof(TOptions);
+
+ if (!OptionsCache.TryGetValue(type, out object value) && ServiceProvider != null)
+ {
+ IOptions options = ServiceProvider.GetService>();
+ value = options.Value;
+ OptionsCache.TryAdd(type, value);
+ }
+
+ return value as TOptions;
+ }
+}
+
+
+public interface IZeroOptions
+{
+ ///
+ /// The currently active version
+ /// This should not be set manually, as it is used for setup and migrations and incremented automatically
+ ///
+ string Version { get; set; }
+
+ ///
+ /// Whether this zero instance is initialized (setup is completed)
+ ///
+ bool Initialized { get; }
+
+ ///
+ /// Expiration of a generated change token for an entity
+ ///
+ TimeSpan TokenExpiration { get; set; }
+
+ ///
+ /// Get typed options (proxy to IOptions)
+ ///
+ TOptions For() where TOptions : class;
+}
diff --git a/zero/Configuration/ZeroStartupOptions.cs b/zero/Configuration/ZeroStartupOptions.cs
new file mode 100644
index 00000000..cc11bcb8
--- /dev/null
+++ b/zero/Configuration/ZeroStartupOptions.cs
@@ -0,0 +1,23 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace zero.Configuration;
+
+public class ZeroStartupOptions : IZeroStartupOptions
+{
+ public IList AssemblyDiscoveryRules { get; private set; } = new List();
+
+ public IMvcBuilder Mvc { get; private set; }
+
+
+ public ZeroStartupOptions(IMvcBuilder mvc)
+ {
+ Mvc = mvc;
+ }
+}
+
+public interface IZeroStartupOptions
+{
+ IList AssemblyDiscoveryRules { get; }
+
+ IMvcBuilder Mvc { get; }
+}
\ No newline at end of file
diff --git a/zero/Context/ZeroContext.cs b/zero/Context/ZeroContext.cs
new file mode 100644
index 00000000..b7bfae84
--- /dev/null
+++ b/zero/Context/ZeroContext.cs
@@ -0,0 +1,113 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using System.Security.Claims;
+
+namespace zero.Context;
+
+public class ZeroContext : IZeroContext
+{
+ ///
+ public IZeroOptions Options { get; protected set; }
+
+ ///
+ public IZeroStore Store { get; private set; }
+
+ ///
+ public IServiceProvider Services { get; private set; }
+
+ protected ICultureResolver CultureResolver { get; private set; }
+
+ protected ILogger Logger { get; private set; }
+
+ protected IHandlerHolder Handler { get; private set; }
+
+ protected IHttpContextAccessor HttpContextAccessor { get; private set; }
+
+ protected IPrimitiveTypeCollection ValueCollection { get; private set; }
+
+
+ bool _resolved = false;
+
+
+ public ZeroContext(IZeroOptions options, IHttpContextAccessor httpContextAccessor, ICultureResolver cultureResolver,
+ ILogger logger, IZeroStore store, IHandlerHolder handler, IServiceProvider services)
+ {
+ Options = options;
+ CultureResolver = cultureResolver;
+ Logger = logger;
+ Store = store;
+ Handler = handler;
+ ValueCollection = new PrimitiveTypeCollection();
+ HttpContextAccessor = httpContextAccessor;
+ Services = services;
+ }
+
+
+ ///
+ public virtual async Task Resolve(HttpContext context)
+ {
+ if (_resolved)
+ {
+ return;
+ }
+
+ _resolved = true;
+
+ // set current culture
+ await CultureResolver.Resolve(this);
+ }
+
+
+ ///
+ public T Get() => ValueCollection.Get();
+
+
+ ///
+ public void Set(T value) => ValueCollection.Set(value);
+
+
+ ///
+ public void Remove() => ValueCollection.Remove();
+}
+
+
+
+public interface IZeroContext
+{
+ ///
+ /// Global zero options
+ ///
+ IZeroOptions Options { get; }
+
+ ///
+ /// Document store
+ ///
+ IZeroStore Store { get; }
+
+ ///
+ /// Service container
+ ///
+ IServiceProvider Services { get; }
+
+ ///
+ /// Resolves the current application (for backoffice + frontend requests) and
+ /// the currently active backoffice user, as users are not signed in with the default scheme and do therefore not populate HttpContext.User
+ ///
+ Task Resolve(HttpContext context);
+
+ ///
+ /// Get a custom property from this scoped context
+ ///
+ T Get();
+
+ ///
+ /// Add a custom property to this scoped context
+ ///
+ void Set(T value);
+
+ ///
+ /// Remove a custom property from this scoped context
+ ///
+ void Remove();
+}
\ No newline at end of file
diff --git a/zero/Context/ZeroContextMiddleware.cs b/zero/Context/ZeroContextMiddleware.cs
new file mode 100644
index 00000000..c1325774
--- /dev/null
+++ b/zero/Context/ZeroContextMiddleware.cs
@@ -0,0 +1,20 @@
+using Microsoft.AspNetCore.Http;
+
+namespace zero.Context
+{
+ public class ZeroContextMiddleware
+ {
+ RequestDelegate _next;
+
+ public ZeroContextMiddleware(RequestDelegate next)
+ {
+ _next = next;
+ }
+
+ public async Task Invoke(HttpContext httpContext, IZeroContext zeroContext)
+ {
+ await zeroContext.Resolve(httpContext);
+ await _next(httpContext);
+ }
+ }
+}
diff --git a/zero/Context/ZeroContextModule.cs b/zero/Context/ZeroContextModule.cs
new file mode 100644
index 00000000..63083b08
--- /dev/null
+++ b/zero/Context/ZeroContextModule.cs
@@ -0,0 +1,13 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace zero.Context;
+
+internal class ZeroContextModule : ZeroModule
+{
+ public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddScoped();
+ services.AddHttpContextAccessor();
+ }
+}
\ No newline at end of file
diff --git a/zero/Extensions/CharExtensions.cs b/zero/Extensions/CharExtensions.cs
new file mode 100644
index 00000000..1560fc9b
--- /dev/null
+++ b/zero/Extensions/CharExtensions.cs
@@ -0,0 +1,57 @@
+namespace zero.Extensions;
+
+public static class CharExtensions
+{
+ private static Dictionary accents { get; } = new Dictionary()
+ {
+ { 'ä', new char[2] { 'a', 'e' } },
+ { 'á', new char[1] { 'a' } },
+ { 'à', new char[1] { 'a' } },
+ { 'ó', new char[1] { 'o' } },
+ { 'ò', new char[1] { 'o' } },
+ { 'é', new char[1] { 'e' } },
+ { 'è', new char[1] { 'e' } },
+ { 'ú', new char[1] { 'u' } },
+ { 'ù', new char[1] { 'u' } },
+ { 'í', new char[1] { 'i' } },
+ { 'ì', new char[1] { 'i' } },
+ { 'ö', new char[2] { 'o', 'e' } },
+ { 'ü', new char[2] { 'u', 'e' } },
+ { 'ß', new char[2] { 's', 's' } },
+ { '&', new char[1] { '+' } }
+ };
+
+
+ ///
+ /// Check if a character is from a-z, A-Z or 0-9
+ ///
+ public static bool IsAZor09(this char value)
+ {
+ return (value >= 0x41 && value <= 0x5A) || (value >= 0x61 && value <= 0x7a) || (value >= 0x30 && value <= 0x39);
+ }
+
+
+ ///
+ /// Check if a character is in ASCII range
+ ///
+ public static bool IsASCII(this char value)
+ {
+ return value < 128;
+ }
+
+
+ ///
+ /// Replaces an accent or umlaut with the appropriate URL + file ready variant
+ ///
+ public static bool TryReplaceAccent(this char value, out char[] result)
+ {
+ if (!accents.ContainsKey(value))
+ {
+ result = null;
+ return false;
+ }
+
+ result = accents[value];
+ return true;
+ }
+}
diff --git a/zero/Extensions/ColorExtensions.cs b/zero/Extensions/ColorExtensions.cs
new file mode 100644
index 00000000..0d35f1d1
--- /dev/null
+++ b/zero/Extensions/ColorExtensions.cs
@@ -0,0 +1,58 @@
+using System.Drawing;
+
+namespace zero.Extensions;
+
+public static class ColorExtensions
+{
+ ///
+ /// Get color object from a hex string
+ ///
+ public static Color FromHex(this string color)
+ {
+ if (color.StartsWith("#"))
+ {
+ color = color[1..];
+ }
+
+ if (color.Length != 6)
+ {
+ throw new Exception("Hex color has to be in format #RRGGBB");
+ }
+
+ return Color.FromArgb(
+ int.Parse(color.Substring(0, 2), System.Globalization.NumberStyles.HexNumber),
+ int.Parse(color.Substring(2, 2), System.Globalization.NumberStyles.HexNumber),
+ int.Parse(color.Substring(4, 2), System.Globalization.NumberStyles.HexNumber)
+ );
+ }
+
+
+ ///
+ /// Get contrast ratio between two color values
+ ///
+ ///
+ public static double GetContrastRatio(this Color color1, Color color2)
+ {
+ double lum1 = color1.GetLuminance();
+ double lum2 = color2.GetLuminance();
+ double brightest = Math.Max(lum1, lum2);
+ double darkest = Math.Min(lum1, lum2);
+ return (brightest + 0.05) / (darkest + 0.05);
+ }
+
+
+ ///
+ /// Get luminance value of a color based on WCAG-conform JS implementation
+ ///
+ ///
+ public static double GetLuminance(this Color color)
+ {
+ double[] a = new double[3] { color.R, color.G, color.B }.Select(v =>
+ {
+ v /= 255;
+ return v <= 0.03928 ? v / 12.92 : Math.Pow((v + 0.055) / 1.055, 2.4);
+ }).ToArray();
+
+ return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
+ }
+}
diff --git a/zero/Extensions/DictionaryExtensions.cs b/zero/Extensions/DictionaryExtensions.cs
new file mode 100644
index 00000000..6575f3a8
--- /dev/null
+++ b/zero/Extensions/DictionaryExtensions.cs
@@ -0,0 +1,36 @@
+namespace zero.Extensions;
+
+public static class DictionaryExtensions
+{
+ public static bool TryGetValue(this Dictionary model, string key, out T value)
+ {
+ if (!model.TryGetValue(key, out object valueObj) || !(valueObj is T))
+ {
+ value = default;
+ return false;
+ }
+
+ value = (T)valueObj;
+ return true;
+ }
+
+
+ public static T GetValueOrDefault(this Dictionary model, string key)
+ {
+ object value = model.GetValueOrDefault(key);
+ return value == default || !(value is T) ? default : (T)value;
+ }
+
+
+ public static Dictionary ToDistinctDictionary(this IEnumerable source, Func keySelector, Func elementSelector)
+ {
+ Dictionary result = new();
+
+ foreach (TSource sourceElement in source)
+ {
+ result.TryAdd(keySelector(sourceElement), elementSelector(sourceElement));
+ }
+
+ return result;
+ }
+}
diff --git a/zero/Extensions/EnumerableExtensions.cs b/zero/Extensions/EnumerableExtensions.cs
new file mode 100644
index 00000000..9dfd9d9f
--- /dev/null
+++ b/zero/Extensions/EnumerableExtensions.cs
@@ -0,0 +1,36 @@
+namespace zero.Extensions;
+
+public static class EnumerableExtensions
+{
+ public static IEnumerable Paging(this IEnumerable source, int pageNumber, int pageSize)
+ {
+ pageNumber = pageNumber.Limit(1, 10_000_000);
+ pageSize = pageSize.Limit(1, 1_000);
+
+ if (pageNumber <= 0 || pageSize <= 0)
+ {
+ throw new NotSupportedException("Both pageNumber and pageSize must be greater than zero");
+ }
+
+ return source.Skip((pageNumber - 1) * pageSize).Take(pageSize);
+ }
+
+
+ public static bool TryGet(this IEnumerable source, Func predicate, out T model)
+ {
+ model = source.FirstOrDefault(predicate);
+ return model != null;
+ }
+
+
+ public static bool TryAdd(this IList source, T model) where T : class
+ {
+ if (model == default)
+ {
+ return false;
+ }
+
+ source.Add(model);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/zero/Extensions/ExpressionExtensions.cs b/zero/Extensions/ExpressionExtensions.cs
new file mode 100644
index 00000000..589fbc25
--- /dev/null
+++ b/zero/Extensions/ExpressionExtensions.cs
@@ -0,0 +1,48 @@
+using System.Linq.Expressions;
+using System.Text;
+
+namespace zero.Extensions;
+
+public static class ExpressionExtensions
+{
+ internal static string GetPropertyPath(this Expression expr, out MemberExpression member)
+ {
+ StringBuilder path = new();
+ MemberExpression memberExpression = GetMemberExpression(expr);
+ do
+ {
+ member = memberExpression;
+
+ if (path.Length > 0)
+ {
+ path.Insert(0, ".");
+ }
+ path.Insert(0, memberExpression.Member.Name);
+ memberExpression = GetMemberExpression(memberExpression.Expression);
+ }
+ while (memberExpression != null);
+
+ return path.ToString();
+ }
+
+ internal static MemberExpression GetMemberExpression(this Expression expression)
+ {
+ if (expression is MemberExpression)
+ {
+ return (MemberExpression)expression;
+ }
+ else if (expression is LambdaExpression)
+ {
+ var lambdaExpression = expression as LambdaExpression;
+ if (lambdaExpression.Body is MemberExpression)
+ {
+ return (MemberExpression)lambdaExpression.Body;
+ }
+ else if (lambdaExpression.Body is UnaryExpression)
+ {
+ return ((MemberExpression)((UnaryExpression)lambdaExpression.Body).Operand);
+ }
+ }
+ return null;
+ }
+}
diff --git a/zero/Extensions/HttpContextExtensions.cs b/zero/Extensions/HttpContextExtensions.cs
new file mode 100644
index 00000000..eff39b0a
--- /dev/null
+++ b/zero/Extensions/HttpContextExtensions.cs
@@ -0,0 +1,44 @@
+using Microsoft.AspNetCore.Http;
+
+namespace zero.Extensions;
+
+public static class HttpContextExtensions
+{
+ ///
+ /// Whether the current request is an AJAX request
+ ///
+ public static bool IsAjaxRequest(this HttpContext context)
+ {
+ if (context?.Request?.Headers == null)
+ {
+ return false;
+ }
+
+ return context.Request.Headers["X-Requested-With"] == "XMLHttpRequest";
+ }
+
+
+ ///
+ /// Whether the current request only accepts application/json
+ ///
+ public static bool IsJsonRequest(this HttpContext context)
+ {
+ if (context?.Request?.Headers == null)
+ {
+ return false;
+ }
+
+ return context.Request.Headers["Accept"] == "application/json";
+ }
+
+
+ ///
+ /// Get IP Address of the client
+ ///
+ public static string GetClientIpAddress(this HttpContext context)
+ {
+ return context.Connection.RemoteIpAddress?.ToString();
+ //string ipAddress = context.GetServerVariable("HTTP_X_FORWARDED_FOR");
+ //return !ipAddress.IsNullOrEmpty() ? ipAddress.Split(',')[0] : context.GetServerVariable("REMOTE_ADDR");
+ }
+}
diff --git a/zero/Extensions/NumberExtensions.cs b/zero/Extensions/NumberExtensions.cs
new file mode 100644
index 00000000..26f13f4f
--- /dev/null
+++ b/zero/Extensions/NumberExtensions.cs
@@ -0,0 +1,47 @@
+namespace zero.Extensions;
+
+public static class NumberExtensions
+{
+ static Dictionary FileSizeUnits = new()
+ {
+ { FileSizeNotation.SI, new string[] { "B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB" } },
+ { FileSizeNotation.IEC, new string[] { "B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB" } },
+ { FileSizeNotation.JEDEC, new string[] { "B", "KB", "MB", "GB" } }
+ };
+
+ public static int Limit(this int input, int min, int max)
+ {
+ if (input < min)
+ {
+ return min;
+ }
+ if (input > max)
+ {
+ return max;
+ }
+ return input;
+ }
+
+
+ public static string GetFileSize(this long sizeInBytes, FileSizeNotation notation = FileSizeNotation.JEDEC)
+ {
+ if (!FileSizeUnits.ContainsKey(notation))
+ {
+ throw new NotImplementedException($"The notation {notation} has no implementation for generating human-readable file sizes");
+ }
+
+ int power = notation == FileSizeNotation.SI ? 1000 : 1024;
+
+ string[] units = FileSizeUnits[notation];
+
+ int order = 0;
+
+ while (sizeInBytes >= power && order + 1 < units.Length)
+ {
+ order++;
+ sizeInBytes = sizeInBytes / power;
+ }
+
+ return String.Format("{0:0.##} {1}", sizeInBytes, units[order]);
+ }
+}
diff --git a/zero/Extensions/ObjectExtensions.cs b/zero/Extensions/ObjectExtensions.cs
new file mode 100644
index 00000000..4f10bbf0
--- /dev/null
+++ b/zero/Extensions/ObjectExtensions.cs
@@ -0,0 +1,30 @@
+//using Newtonsoft.Json;
+
+//namespace zero.Extensions;
+
+//[Obsolete("we don't want this for every object (use a Utils class instead)")]
+//public static class ObjectExtensions
+//{
+// public static bool Is(this Type type)
+// {
+// return type.IsAssignableFrom(typeof(T));
+// }
+
+
+// public static bool Is(this object obj)
+// {
+// return obj.GetType().IsAssignableFrom(typeof(T));
+// }
+
+
+// public static T Clone(this T obj)
+// {
+// Type type = obj.GetType();
+// return (T)JsonConvert.DeserializeObject(JsonConvert.SerializeObject(obj), type);
+// }
+
+// public static T CloneLax(this object obj)
+// {
+// return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(obj));
+// }
+//}
diff --git a/zero/Extensions/ResultExtensions.cs b/zero/Extensions/ResultExtensions.cs
new file mode 100644
index 00000000..d726b0ab
--- /dev/null
+++ b/zero/Extensions/ResultExtensions.cs
@@ -0,0 +1,48 @@
+namespace zero.Extensions;
+
+public static class ResultExtensions
+{
+ public static Result WithoutModel(this Result origin)
+ {
+ Result result = new();
+ result.IsSuccess = origin.IsSuccess;
+ result.Errors = origin.Errors;
+ return result;
+ }
+
+ public static Result ConvertTo(this Result origin, TTarget model)
+ {
+ Result result = new();
+ result.IsSuccess = origin.IsSuccess;
+ result.Errors = origin.Errors;
+ result.Model = model;
+ return result;
+ }
+
+
+ public static Result AddError(this Result origin, string property, string message)
+ {
+ origin.IsSuccess = false;
+ origin.Errors.Add(new(property, message));
+ return origin;
+ }
+
+
+ public static Result AddError(this Result origin, string message)
+ {
+ origin.IsSuccess = false;
+ origin.Errors.Add(new(Constants.ErrorFieldNone, message));
+ return origin;
+ }
+
+
+ public static Result AppendErrors(this Result origin, Result with)
+ {
+ foreach (ResultError error in with.Errors)
+ {
+ origin.Errors.Add(error);
+ }
+ origin.IsSuccess = origin.Errors.Any();
+ return origin;
+ }
+}
\ No newline at end of file
diff --git a/zero/Extensions/ServiceCollectionExtensions.cs b/zero/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..9d7e0a5c
--- /dev/null
+++ b/zero/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,109 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using System.Reflection;
+
+namespace zero.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Adds all found implementations based on the service type and assembly discovery rules
+ ///
+ public static void AddAll(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Transient, bool asSelf = false) => services.AddAll(typeof(TService), lifetime, asSelf);
+
+
+ ///
+ /// Adds all found implementations based on the service type and assembly discovery rules
+ ///
+ public static void AddAll(this IServiceCollection services, Type serviceType, ServiceLifetime lifetime = ServiceLifetime.Transient, bool asSelf = false)
+ {
+ if (AssemblyDiscovery.Current == null)
+ {
+ throw new Exception("services.AddAll() can only be run after mvcBuilder.AddZero()");
+ }
+
+ // add implementations with generic service types
+ if (serviceType.GetTypeInfo().IsGenericTypeDefinition)
+ {
+ IEnumerable<(Type, TypeInfo)> matches = AssemblyDiscovery.Current.GetAllClassTypes().SelectMany(type =>
+ {
+ var interfaces = type.GetInterfaces().Select(x => x.GetTypeInfo());
+ IEnumerable genericTypes = interfaces.Where(x => x.IsGenericType && x.GetGenericTypeDefinition() == serviceType);
+ if (genericTypes.Any())
+ {
+ var genericTypeDefinitions = genericTypes.First();
+ }
+ return genericTypes.Select(x => (x, type));
+ });
+
+ foreach ((Type, Type) match in matches)
+ {
+ services.Add(new ServiceDescriptor(asSelf ? match.Item2 : match.Item1, match.Item2, lifetime));
+ }
+ }
+ // add implementations with specific service types
+ else
+ {
+ foreach (Type type in AssemblyDiscovery.Current.GetTypes(serviceType))
+ {
+ services.Add(new ServiceDescriptor(asSelf ? type : serviceType, type, lifetime));
+ }
+ }
+ }
+
+
+ ///
+ /// Adds or overrides an implementation
+ ///
+ public static void Replace(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Transient)
+ where TService : class
+ where TImplementation : class, TService
+ {
+ services.RemoveAll();
+ services.Add(new ServiceDescriptor(typeof(TService), typeof(TImplementation), lifetime));
+ }
+
+
+ ///
+ /// Adds or overrides an implementation
+ ///
+ public static void Replace(this IServiceCollection services, Func implementationFactory, ServiceLifetime lifetime = ServiceLifetime.Transient)
+ where TService : class
+ where TImplementation : class, TService
+ {
+ services.RemoveAll();
+ services.Add(new ServiceDescriptor(typeof(TService), implementationFactory, lifetime));
+ }
+
+
+ ///
+ /// Adds or overrides an implementation
+ ///
+ public static void Replace(this IServiceCollection services)
+ where TService : class
+ where TOldImplementation : class, TService
+ where TNewImplementation : class, TService
+ {
+ ServiceDescriptor oldDescriptor = services.FirstOrDefault(x => x.ImplementationType == typeof(TOldImplementation));
+ if (oldDescriptor != null)
+ {
+ services.Remove(oldDescriptor);
+ services.Add(new ServiceDescriptor(typeof(TService), typeof(TNewImplementation), oldDescriptor.Lifetime));
+ }
+ }
+
+
+ ///
+ /// Removes an implementation
+ ///
+ public static void Remove(this IServiceCollection services)
+ where TService : class
+ where TImplementation : class, TService
+ {
+ ServiceDescriptor oldDescriptor = services.FirstOrDefault(x => x.ImplementationType == typeof(TImplementation));
+ if (oldDescriptor != null)
+ {
+ services.Remove(oldDescriptor);
+ }
+ }
+}
diff --git a/zero/Extensions/StringExtensions.cs b/zero/Extensions/StringExtensions.cs
new file mode 100644
index 00000000..6c3b8da7
--- /dev/null
+++ b/zero/Extensions/StringExtensions.cs
@@ -0,0 +1,196 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace zero.Extensions;
+
+public static class StringExtensions
+{
+ const string SPACE = " ";
+
+ static Regex replaceMultipleSpacesRegex { get; } = new Regex("[ ]{2,}", RegexOptions.None);
+
+ static Regex newLineCharsRegex { get; } = new Regex("\t|\n|\r", RegexOptions.None);
+
+
+ public static string FullTrim(this string value)
+ {
+ if (String.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+
+ return replaceMultipleSpacesRegex.Replace(value, SPACE).Trim();
+ }
+
+
+ public static string EnsureStartsWith(this string input, string toStartWith)
+ {
+ if (input.StartsWith(toStartWith)) return input;
+ return toStartWith + input.TrimStart(toStartWith);
+ }
+
+
+ public static string EnsureStartsWith(this string input, char value)
+ {
+ return input.StartsWith(value.ToString(CultureInfo.InvariantCulture)) ? input : value + input;
+ }
+
+
+ public static string EnsureEndsWith(this string input, char value)
+ {
+ return input.EndsWith(value.ToString(CultureInfo.InvariantCulture)) ? input : input + value;
+ }
+
+
+ public static string EnsureEndsWith(this string input, string toEndWith)
+ {
+ return input.EndsWith(toEndWith.ToString(CultureInfo.InvariantCulture)) ? input : input + toEndWith;
+ }
+
+
+ public static string EnsureSurroundedWith(this string input, char toSurroundWith)
+ {
+ return input.EnsureStartsWith(toSurroundWith).EnsureEndsWith(toSurroundWith);
+ }
+
+
+ public static string Or(this string value, string fallback)
+ {
+ return String.IsNullOrWhiteSpace(value) ? fallback : value;
+ }
+
+
+ public static string TrimEnd(this string value, string forRemoving)
+ {
+ if (String.IsNullOrEmpty(value)) return value;
+ if (String.IsNullOrEmpty(forRemoving)) return value;
+
+ while (value.EndsWith(forRemoving, StringComparison.InvariantCultureIgnoreCase))
+ {
+ value = value.Remove(value.LastIndexOf(forRemoving, StringComparison.InvariantCultureIgnoreCase));
+ }
+ return value;
+ }
+
+
+ public static string TrimStart(this string value, string forRemoving)
+ {
+ if (String.IsNullOrEmpty(value)) return value;
+ if (String.IsNullOrEmpty(forRemoving)) return value;
+
+ while (value.StartsWith(forRemoving, StringComparison.InvariantCultureIgnoreCase))
+ {
+ value = value.Substring(forRemoving.Length);
+ }
+ return value;
+ }
+
+
+ public static bool HasValue(this string input)
+ {
+ return !String.IsNullOrWhiteSpace(input);
+ }
+
+
+ public static bool IsNullOrEmpty(this string input)
+ {
+ return String.IsNullOrEmpty(input);
+ }
+
+
+ public static bool IsNullOrWhiteSpace(this string input)
+ {
+ return String.IsNullOrWhiteSpace(input);
+ }
+
+ public static string ToCamelCase(this string input)
+ {
+ if (!String.IsNullOrEmpty(input) && input.Length > 1)
+ {
+ return Char.ToLowerInvariant(input[0]) + input.Substring(1);
+ }
+ return input;
+ }
+
+ public static string ToPascalCase(this string input)
+ {
+ if (!String.IsNullOrEmpty(input) && input.Length > 1)
+ {
+ return Char.ToUpperInvariant(input[0]) + input.Substring(1);
+ }
+ return input;
+ }
+
+ public static string ToCamelCaseId(this string input)
+ {
+ if (String.IsNullOrEmpty(input))
+ {
+ return input;
+ }
+
+ if (input.Length < 2)
+ {
+ return input.ToLowerInvariant();
+ }
+
+ string[] parts = input.Split('.');
+
+ return String.Join(".", parts.Select(x => x.ToCamelCase()));
+ }
+
+
+ public static string ToPascalCaseId(this string input)
+ {
+ if (String.IsNullOrEmpty(input))
+ {
+ return input;
+ }
+
+ if (input.Length < 2)
+ {
+ return input.ToUpperInvariant();
+ }
+
+ string[] parts = input.Split('.');
+
+ return String.Join(".", parts.Select(x => x.ToPascalCase()));
+ }
+
+
+ public static string RemoveNewLines(this string input)
+ {
+ if (String.IsNullOrEmpty(input))
+ {
+ return input;
+ }
+
+ return newLineCharsRegex.Replace(input, String.Empty).Trim();
+ }
+
+
+ public static string Shorten(this string input, int maxLength)
+ {
+ if (maxLength < 4)
+ {
+ throw new ArgumentOutOfRangeException("maxLength", "Has to be at least 4 characters long");
+ }
+
+ if (String.IsNullOrEmpty(input) || input.Length <= maxLength)
+ {
+ return input;
+ }
+
+ return input.Substring(0, maxLength - 3) + "...";
+ }
+
+
+ public static string ReplaceFirst(this string text, string search, string replace)
+ {
+ int pos = text.IndexOf(search);
+ if (pos < 0)
+ {
+ return text;
+ }
+ return text.Substring(0, pos) + replace + text.Substring(pos + search.Length);
+ }
+}
diff --git a/zero/FileStorage/FileResult.cs b/zero/FileStorage/FileResult.cs
new file mode 100644
index 00000000..d4bcc98b
--- /dev/null
+++ b/zero/FileStorage/FileResult.cs
@@ -0,0 +1,10 @@
+namespace zero.FileStorage;
+
+public class FileResult
+{
+ public string Filename { get; set; }
+
+ public string Path { get; set; }
+
+ public string ContentType { get; set; }
+}
diff --git a/zero/FileStorage/FileSizeNotation.cs b/zero/FileStorage/FileSizeNotation.cs
new file mode 100644
index 00000000..d85a36e0
--- /dev/null
+++ b/zero/FileStorage/FileSizeNotation.cs
@@ -0,0 +1,18 @@
+namespace zero.FileStorage;
+
+public enum FileSizeNotation
+{
+ ///
+ /// Multiply by 1.000, as specified by the International System of Units (e.g. kB, MB, GB)
+ ///
+ SI = 0,
+ ///
+ /// Binary notation with a multiplier of 1.024 (e.g. KiB, MiB, GiB)
+ ///
+ IEC = 1,
+ ///
+ /// Binary notation with a multiplier of 1.024 (e.g. KB, MB, GB).
+ /// Ends with GB, there is no TB, ...
+ ///
+ JEDEC = 2
+}
diff --git a/zero/FileStorage/FileSystemException.cs b/zero/FileStorage/FileSystemException.cs
new file mode 100644
index 00000000..fc9d39b0
--- /dev/null
+++ b/zero/FileStorage/FileSystemException.cs
@@ -0,0 +1,10 @@
+namespace zero.FileStorage;
+
+public class FileSystemException : Exception
+{
+ public FileSystemException() { }
+
+ public FileSystemException(string message) : base(message) { }
+
+ public FileSystemException(string message, Exception innerException) : base(message, innerException) { }
+}
\ No newline at end of file
diff --git a/zero/FileStorage/FileSystemOptions.cs b/zero/FileStorage/FileSystemOptions.cs
new file mode 100644
index 00000000..97c25f95
--- /dev/null
+++ b/zero/FileStorage/FileSystemOptions.cs
@@ -0,0 +1,6 @@
+namespace zero.FileStorage;
+
+public class FileSystemOptions
+{
+ public string ZeroAssetsPath { get; set; }
+}
\ No newline at end of file
diff --git a/zero/FileStorage/IFileMeta.cs b/zero/FileStorage/IFileMeta.cs
new file mode 100644
index 00000000..cd6b49dd
--- /dev/null
+++ b/zero/FileStorage/IFileMeta.cs
@@ -0,0 +1,44 @@
+namespace zero.FileStorage;
+
+public interface IFileMeta
+{
+ ///
+ /// Full path of the item within the file system
+ ///
+ string Path { get; }
+
+ ///
+ /// Absolute path of the item
+ ///
+ string AbsolutePath { get; }
+
+ ///
+ /// Name of the item
+ ///
+ string Name { get; }
+
+ ///
+ /// Full path to the directory containing the item
+ ///
+ string DirectoryPath { get; }
+
+ ///
+ /// Length of the file in bytes
+ ///
+ long Length { get; }
+
+ ///
+ /// Date when the item was last modified
+ ///
+ DateTimeOffset LastModifiedDate { get; }
+
+ ///
+ /// Whether this item is directory or a file
+ ///
+ bool IsDirectory { get; }
+
+ ///
+ /// Additional properties
+ ///
+ Dictionary Properties { get; }
+}
diff --git a/zero/FileStorage/IFileSystem.cs b/zero/FileStorage/IFileSystem.cs
new file mode 100644
index 00000000..bd8c5238
--- /dev/null
+++ b/zero/FileStorage/IFileSystem.cs
@@ -0,0 +1,71 @@
+using System.IO;
+
+namespace zero.FileStorage;
+
+public interface IFileSystem
+{
+ ///
+ /// Map a relative path to an absolute internal path
+ ///
+ string Map(string path);
+
+ ///
+ /// Map an internal path to a public accessible path
+ ///
+ string MapToPublicPath(string path);
+
+ ///
+ /// Determines whether a file or directory at the given path already exists
+ ///
+ Task Exists(string path, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get information of a file
+ ///
+ Task GetFileInfo(string path, CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns a readable stream for a file
+ ///
+ Task StreamFile(string path, CancellationToken cancellationToken = default);
+
+ ///
+ /// Creates a file at the given path
+ ///
+ Task CreateFile(string path, Stream fileStream, bool overwrite = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a file at the given path. This method returns if the file does not exist.
+ ///
+ Task DeleteFile(string path, CancellationToken cancellationToken = default);
+
+ ///
+ /// Moves a file to another path (can also be used to rename resources)
+ ///
+ Task MoveFile(string oldPath, string newPath, CancellationToken cancellationToken = default);
+
+ ///
+ /// Copies a file to another path
+ ///
+ Task CopyFile(string oldPath, string newPath, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get information of a directory
+ ///
+ Task GetDirectoryInfo(string path, CancellationToken cancellationToken = default);
+
+ ///
+ /// Get all items within a directory
+ ///
+ IAsyncEnumerable GetDirectoryContent(string path = null, bool recursive = false, CancellationToken cancellationToken = default);
+
+ ///
+ /// Tries to create a directory at the given path. This method returns if the directory already exists.
+ ///
+ Task CreateDirectory(string path, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a directory at the given path. This method returns if the directory does not exist.
+ ///
+ Task DeleteDirectory(string path, bool recursive = false, CancellationToken cancellationToken = default);
+}
\ No newline at end of file
diff --git a/zero/FileStorage/Paths.cs b/zero/FileStorage/Paths.cs
new file mode 100644
index 00000000..602ab9de
--- /dev/null
+++ b/zero/FileStorage/Paths.cs
@@ -0,0 +1,217 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.StaticFiles;
+using System.IO;
+using System.Text;
+
+namespace zero.FileStorage;
+
+public class Paths : IPaths
+{
+ public string WebRoot { get; set; }
+
+ public string ContentRoot { get; set; }
+
+ public string SecureRoot { get; set; }
+
+ public string Media { get; set; }
+
+ protected IWebHostEnvironment Env { get; set; }
+
+ public const string MEDIA_FOLDER = "Uploads";
+
+ public const char PATH_SEPARATOR = '/';
+
+ static char[] InvalidFilenameChars = null;
+
+ const char REPLACEMENT_CHAR = '-';
+
+ FileExtensionContentTypeProvider FileExtensionContentTypeProvider { get; set; }
+
+ static readonly Dictionary Replacements = new()
+ {
+ { 'ä', "ae" },
+ { 'ü', "ue" },
+ { 'ö', "oe" },
+ { 'ß', "ss" }
+ };
+
+
+ public Paths(IWebHostEnvironment env)
+ {
+ Env = env;
+ WebRoot = env.WebRootPath;
+ ContentRoot = env.ContentRootPath;
+ SecureRoot = Path.Combine(ContentRoot, "wwwroot.secure");
+ Media = Path.Combine(WebRoot, MEDIA_FOLDER);
+ FileExtensionContentTypeProvider = new();
+ }
+
+
+ ///
+ /// Combine a path
+ ///
+ public string Combine(params string[] paths)
+ {
+ return Path.Combine(paths);
+ }
+
+
+ ///
+ /// Map a path
+ ///
+ public string Map(string path)
+ {
+ return Path.Combine(WebRoot, path);
+ }
+
+
+ ///
+ /// Map a secure path
+ ///
+ public string MapSecure(string path)
+ {
+ return Path.Combine(SecureRoot, path);
+ }
+
+
+ ///
+ /// Map a path
+ ///
+ public string Map(params string[] paths)
+ {
+ return Path.Combine(WebRoot, Path.Combine(paths));
+ }
+
+
+ ///
+ /// Map a secure path
+ ///
+ public string MapSecure(params string[] paths)
+ {
+ return Path.Combine(SecureRoot, Path.Combine(paths));
+ }
+
+
+ ///
+ /// Create a directory if it does not exist yet
+ ///
+ public void Create(string directory)
+ {
+ if (!Directory.Exists(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+ }
+
+
+ ///
+ /// Get content type for a filename
+ ///
+ public string GetContentType(string filename, string fallback = "application/octet-stream")
+ {
+ if (filename == null || !FileExtensionContentTypeProvider.TryGetContentType(Path.GetFileName(filename), out string contentType))
+ {
+ contentType = fallback;
+ }
+ return contentType;
+ }
+
+
+ ///
+ /// Normalizes a filename and removes invalid chars
+ ///
+ public string ToFilename(string value)
+ {
+ if (String.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+
+ // lowercase for string
+ value = value.ToLower();
+
+ StringBuilder sb = new(value.Length);
+
+ var invalids = InvalidFilenameChars ??= Path.GetInvalidFileNameChars();
+ bool changed = false;
+
+ for (int i = 0; i < value.Length; i++)
+ {
+ char c = value[i];
+
+ if (Replacements.ContainsKey(c))
+ {
+ changed = true;
+ sb.Append(Replacements[c]);
+ }
+ else if (invalids.Contains(c))
+ {
+ changed = true;
+ sb.Append(REPLACEMENT_CHAR);
+ }
+ else
+ {
+ sb.Append(c);
+ }
+ }
+
+ if (sb.Length == 0)
+ {
+ return "_";
+ }
+
+ return changed ? sb.ToString() : value;
+ }
+}
+
+
+public interface IPaths
+{
+ string ContentRoot { get; set; }
+
+ string WebRoot { get; set; }
+
+ string SecureRoot { get; set; }
+
+ string Media { get; set; }
+
+ ///
+ /// Combine a path
+ ///
+ string Combine(params string[] paths);
+
+ ///
+ /// Map a path
+ ///
+ string Map(string path);
+
+ ///
+ /// Map a secure path
+ ///
+ string MapSecure(string path);
+
+ ///
+ /// Map a path
+ ///
+ string Map(params string[] paths);
+
+ ///
+ /// Map a secure path
+ ///
+ string MapSecure(params string[] paths);
+
+ ///
+ /// Create a directory if it does not exist yet
+ ///
+ void Create(string directory);
+
+ ///
+ /// Get content type for a filename
+ ///
+ string GetContentType(string filename, string fallback = "application/octet-stream");
+
+ ///
+ /// Normalizes a filename and removes invalid chars
+ ///
+ string ToFilename(string value);
+}
\ No newline at end of file
diff --git a/zero/FileStorage/PhysicalFileMeta.cs b/zero/FileStorage/PhysicalFileMeta.cs
new file mode 100644
index 00000000..6f89c6dc
--- /dev/null
+++ b/zero/FileStorage/PhysicalFileMeta.cs
@@ -0,0 +1,43 @@
+using Microsoft.Extensions.FileProviders;
+
+namespace zero.FileStorage;
+
+public class PhysicalFileMeta : IFileMeta
+{
+ ///
+ public string Name { get; }
+
+ ///
+ public string Path { get; }
+
+ ///
+ public string AbsolutePath { get; }
+
+ ///
+ public string DirectoryPath { get; }
+
+ ///
+ public long Length { get; }
+
+ ///
+ public DateTimeOffset LastModifiedDate { get; }
+
+ ///
+ public bool IsDirectory { get; }
+
+ ///
+ public Dictionary Properties { get; }
+
+
+ public PhysicalFileMeta(IFileInfo fileInfo, string path)
+ {
+ Path = path;
+ AbsolutePath = fileInfo.PhysicalPath;
+ Name = fileInfo.Name;
+ DirectoryPath = path.Substring(0, path.Length - fileInfo.Name.Length).TrimEnd('/');
+ LastModifiedDate = fileInfo.LastModified;
+ Length = fileInfo.Length;
+ IsDirectory = fileInfo.IsDirectory;
+ Properties = new();
+ }
+}
\ No newline at end of file
diff --git a/zero/FileStorage/PhysicalFileSystem.cs b/zero/FileStorage/PhysicalFileSystem.cs
new file mode 100644
index 00000000..c5b3cf33
--- /dev/null
+++ b/zero/FileStorage/PhysicalFileSystem.cs
@@ -0,0 +1,305 @@
+using Microsoft.Extensions.FileProviders.Physical;
+using System.IO;
+
+namespace zero.FileStorage;
+
+public class PhysicalFileSystem : IFileSystem
+{
+ readonly string _root;
+
+
+ public PhysicalFileSystem(string root)
+ {
+ _root = root;
+ }
+
+
+ ///
+ public virtual string Map(string path)
+ {
+ return ResolvePath(path);
+ }
+
+
+ ///
+ public virtual string MapToPublicPath(string path)
+ {
+ return path.EnsureStartsWith('/');
+ }
+
+
+ ///
+ public virtual Task Exists(string path, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+ return Task.FromResult(File.Exists(resolvedPath) || Directory.Exists(resolvedPath));
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not get file exists info for path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public virtual Task GetFileInfo(string path, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+
+ PhysicalFileInfo fileInfo = new(new FileInfo(resolvedPath));
+
+ if (!fileInfo.Exists)
+ {
+ return Task.FromResult(default);
+ }
+
+ return Task.FromResult(new PhysicalFileMeta(fileInfo, path));
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not get file info for path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public virtual Task StreamFile(string path, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+
+ if (resolvedPath.IsNullOrEmpty() || !File.Exists(resolvedPath))
+ {
+ throw new FileSystemException($"The path '{path}' does not exist in the file system");
+ }
+
+ return Task.FromResult(File.OpenRead(resolvedPath));
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not get file stream for path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public virtual async Task CreateFile(string path, Stream fileStream, bool overwrite = false, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+
+ if ((!overwrite && File.Exists(resolvedPath)) || Directory.Exists(resolvedPath))
+ {
+ throw new FileSystemException($"A file/directory at the path '{path}' already existsin the file system");
+ }
+
+ string directoryPath = Path.GetDirectoryName(resolvedPath);
+ Directory.CreateDirectory(directoryPath);
+
+ FileInfo fileInfo = new(resolvedPath);
+ using var outputStream = fileInfo.Create();
+ await fileStream.CopyToAsync(outputStream, cancellationToken);
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not get file stream for path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public virtual Task DeleteFile(string path, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+ File.Delete(resolvedPath);
+ return Task.CompletedTask;
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not delete file for path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public virtual Task MoveFile(string oldPath, string newPath, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedOldPath = ResolvePath(oldPath);
+ string resolvedNewPath = ResolvePath(newPath);
+
+ if (!File.Exists(resolvedOldPath))
+ {
+ throw new FileSystemException($"The path '{oldPath}' does not exist in the file system");
+ }
+
+ if (File.Exists(resolvedNewPath) || Directory.Exists(resolvedNewPath))
+ {
+ throw new FileSystemException($"A file/directory at the path '{newPath}' already exists in the file system");
+ }
+
+ File.Move(resolvedOldPath, resolvedNewPath);
+ return Task.CompletedTask;
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not move file from path '{oldPath}' to '{newPath}'", ex);
+ }
+ }
+
+
+ ///
+ public virtual Task CopyFile(string oldPath, string newPath, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedOldPath = ResolvePath(oldPath);
+ string resolvedNewPath = ResolvePath(newPath);
+
+ if (!File.Exists(resolvedOldPath))
+ {
+ throw new FileSystemException($"The path '{oldPath}' does not exist in the file system");
+ }
+
+ if (File.Exists(resolvedNewPath) || Directory.Exists(resolvedNewPath))
+ {
+ throw new FileSystemException($"A file/directory at the path '{newPath}' already exists in the file system");
+ }
+
+ File.Copy(resolvedOldPath, resolvedNewPath);
+ return Task.CompletedTask;
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not copy file from path '{oldPath}' to '{newPath}'", ex);
+ }
+ }
+
+
+ ///
+ public virtual Task GetDirectoryInfo(string path, CancellationToken cancellationToken = default) => GetFileInfo(path, cancellationToken);
+
+
+ ///
+ public virtual IAsyncEnumerable GetDirectoryContent(string path = null, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+ List results = new();
+ SearchOption searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
+
+ if (!Directory.Exists(resolvedPath))
+ {
+ return results.ToAsyncEnumerable();
+ }
+
+ results.AddRange(Directory.GetDirectories(resolvedPath, "*", searchOption).Select(f =>
+ {
+ PhysicalDirectoryInfo fileSystemInfo = new(new DirectoryInfo(f));
+ return new PhysicalFileMeta(fileSystemInfo, ResolvePath(f.Substring(_root.Length)));
+ }));
+
+ results.AddRange(Directory.GetFiles(resolvedPath, "*", searchOption).Select(f =>
+ {
+ PhysicalFileInfo fileSystemInfo = new(new FileInfo(f));
+ return new PhysicalFileMeta(fileSystemInfo, ResolvePath(f.Substring(_root.Length)));
+ }));
+
+ return results.ToAsyncEnumerable();
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not get directory content for path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public virtual Task CreateDirectory(string path, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+
+ if (File.Exists(resolvedPath))
+ {
+ throw new FileSystemException($"A file at the path '{path}' already exists in the file system");
+ }
+
+ if (!Directory.Exists(resolvedPath))
+ {
+ Directory.CreateDirectory(resolvedPath);
+ }
+
+ return Task.CompletedTask;
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not create directory at path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ public virtual Task DeleteDirectory(string path, bool recursive = false, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ string resolvedPath = ResolvePath(path);
+ Directory.Delete(resolvedPath, recursive);
+ return Task.CompletedTask;
+ }
+ catch (Exception ex) when (ex is not FileSystemException)
+ {
+ throw new FileSystemException($"Could not delete directory at path '{path}'", ex);
+ }
+ }
+
+
+ ///
+ /// Creates a fully-usable absolte path from the given path
+ ///
+ protected virtual string ResolvePath(string path)
+ {
+ try
+ {
+ if (path.IsNullOrWhiteSpace())
+ {
+ return _root;
+ }
+
+ // normalize path
+ path = path.Replace('\\', '/').FullTrim().Trim('/');
+
+ string physicalPath = Path.Combine(_root, path);
+ string rootPath = Path.GetFullPath(_root);
+
+ // do not allow paths which are outside this file system
+ // the boundaries are set be the basePath which is assigned in the file system constructor
+ if (!Path.GetFullPath(physicalPath).StartsWith(rootPath, StringComparison.InvariantCultureIgnoreCase))
+ {
+ throw new FileSystemException($"The path '{path}' is outside the file system");
+ }
+
+ return physicalPath;
+ }
+ catch (FileSystemException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new FileSystemException($"Could not resolve path '{path}'", ex);
+ }
+ }
+}
diff --git a/zero/FileStorage/WebRootFileSystem.cs b/zero/FileStorage/WebRootFileSystem.cs
new file mode 100644
index 00000000..2e09cead
--- /dev/null
+++ b/zero/FileStorage/WebRootFileSystem.cs
@@ -0,0 +1,28 @@
+namespace zero.FileStorage;
+
+public class WebRootFileSystem : PhysicalFileSystem, IWebRootFileSystem
+{
+ protected string PublicPathPrefix { get; }
+
+
+ public WebRootFileSystem(string root, string publicPathPrefix) : base(root)
+ {
+ PublicPathPrefix = (publicPathPrefix ?? String.Empty).EnsureEndsWith('/');
+ }
+
+
+ ///
+ public override string MapToPublicPath(string path)
+ {
+ if (path.IsNullOrEmpty())
+ {
+ return null;
+ }
+ return PublicPathPrefix + path.TrimStart('/');
+ }
+}
+
+
+public interface IWebRootFileSystem : IFileSystem
+{
+}
\ No newline at end of file
diff --git a/zero/FileStorage/ZeroFileStorageModule.cs b/zero/FileStorage/ZeroFileStorageModule.cs
new file mode 100644
index 00000000..f195cdc0
--- /dev/null
+++ b/zero/FileStorage/ZeroFileStorageModule.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace zero.FileStorage;
+
+internal class ZeroFileStorageModule : ZeroModule
+{
+ public override void ConfigureServices(IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddScoped(factory => new Paths(factory.GetService()));
+
+ services.AddOptions().Bind(configuration.GetSection("Zero:FileSystem")).Configure(opts =>
+ {
+ opts.ZeroAssetsPath = "zero";
+ });
+
+ services.AddSingleton(svc =>
+ {
+ IWebHostEnvironment env = svc.GetRequiredService();
+ return new(env.WebRootPath, null);
+ });
+ }
+}
\ No newline at end of file
diff --git a/zero/Localization/CultureResolver.cs b/zero/Localization/CultureResolver.cs
new file mode 100644
index 00000000..c1931c24
--- /dev/null
+++ b/zero/Localization/CultureResolver.cs
@@ -0,0 +1,59 @@
+using FluentValidation;
+using Microsoft.Extensions.Logging;
+using Raven.Client.Documents;
+using Raven.Client.Documents.Session;
+using System.Globalization;
+using System.Security.Claims;
+
+namespace zero.Localization;
+
+public class CultureResolver : ICultureResolver
+{
+ protected ILogger Logger { get; private set; }
+
+
+ public CultureResolver(ILogger logger)
+ {
+ Logger = logger;
+ }
+
+
+ ///
+ public async Task Resolve(IZeroContext context)
+ {
+ //var session = context.Store.Session();
+ //Language language = await session.Query().FirstOrDefaultAsync();
+ string isoCode = "de-AT";
+
+ try
+ {
+ CultureInfo culture = CultureInfo.CreateSpecificCulture(isoCode);
+
+ if (culture.ThreeLetterISOLanguageName.IsNullOrEmpty())
+ {
+ throw new Exception("ThreeLetterISOLanguageName is empty");
+ }
+
+ CultureInfo.CurrentCulture = culture;
+ CultureInfo.CurrentUICulture = culture;
+ ValidatorOptions.Global.LanguageManager.Culture = culture;
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Could not create culture from Language code {code}", isoCode);
+ return CultureInfo.CurrentCulture;
+ }
+
+ return CultureInfo.CurrentCulture;
+ }
+}
+
+
+public interface ICultureResolver
+{
+ ///
+ /// Resolves the current application from either the backoffice user (in case it is backoffice request)
+ /// or the domain (in case it is frontend request).
+ ///
+ Task Resolve(IZeroContext context);
+}
diff --git a/zero/Localization/CultureService.cs b/zero/Localization/CultureService.cs
new file mode 100644
index 00000000..b1a111a4
--- /dev/null
+++ b/zero/Localization/CultureService.cs
@@ -0,0 +1,31 @@
+using System.Globalization;
+
+namespace zero.Localization;
+
+public class CultureService : ICultureService
+{
+ ///
+ public List GetAllCultures(params string[] codes)
+ {
+ return CultureInfo.GetCultures(CultureTypes.AllCultures)
+ .Where(x => !x.Name.IsNullOrWhiteSpace())
+ .Select(x => new CultureInfo(x.Name))
+ .Where(x => codes.Length == 0 || codes.Contains(x.Name, StringComparer.InvariantCultureIgnoreCase))
+ .OrderBy(x => x.DisplayName)
+ .Select(x => new Culture()
+ {
+ Code = x.Name,
+ Name = x.DisplayName
+ })
+ .ToList();
+ }
+}
+
+
+public interface ICultureService
+{
+ ///
+ /// Get all available cultures
+ ///
+ List GetAllCultures(params string[] codes);
+}
\ No newline at end of file
diff --git a/zero/Localization/LocalizeAttribute.cs b/zero/Localization/LocalizeAttribute.cs
new file mode 100644
index 00000000..aa8c9de2
--- /dev/null
+++ b/zero/Localization/LocalizeAttribute.cs
@@ -0,0 +1,12 @@
+namespace zero.Localization;
+
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Field, AllowMultiple = false)]
+public class LocalizeAttribute : Attribute
+{
+ public string Key;
+
+ public LocalizeAttribute(string key)
+ {
+ Key = key;
+ }
+}
diff --git a/zero/Localization/Localizer.cs b/zero/Localization/Localizer.cs
new file mode 100644
index 00000000..5d89a598
--- /dev/null
+++ b/zero/Localization/Localizer.cs
@@ -0,0 +1,116 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+
+namespace zero.Localization;
+
+public class Localizer : ILocalizer
+{
+ protected ConcurrentDictionary Cache { get; private set; } = new();
+
+ protected IZeroStore Store { get; private set; }
+
+ public Localizer(IZeroStore store)
+ {
+ Store = store;
+ }
+
+
+ ///
+ public string Text(string key) => Text(key, null);
+
+
+ ///
+ public string Text(string key, Dictionary tokens)
+ {
+ if (key.IsNullOrEmpty())
+ {
+ return null;
+ }
+
+ if (!Cache.TryGetValue(key, out string value))
+ {
+ Translation translation = LoadTranslation(key);
+
+ if (translation == null)
+ {
+ return null;
+ }
+
+ value = translation.Value;
+ Cache.TryAdd(key, value);
+ }
+
+ if (tokens != null)
+ {
+ value = TokenReplacement.Apply(value, tokens);
+ }
+
+ return value;
+ }
+
+
+ ///