diff --git a/zero.Core/Extensions/HttpContextExtensions.cs b/zero.Core/Extensions/HttpContextExtensions.cs
index c7cd9662..107b0094 100644
--- a/zero.Core/Extensions/HttpContextExtensions.cs
+++ b/zero.Core/Extensions/HttpContextExtensions.cs
@@ -2,7 +2,7 @@
namespace zero.Core.Extensions
{
- internal static class HttpContextExtensions
+ public static class HttpContextExtensions
{
///
/// Whether the current request is a backoffice request
@@ -12,5 +12,33 @@ namespace zero.Core.Extensions
string path = backofficePath.EnsureStartsWith('/').TrimEnd('/');
return context.Request.Path.ToString().StartsWith(path);
}
+
+
+ ///
+ /// 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";
+ }
}
}
diff --git a/zero.Core/Extensions/RavenDocumentStoreExtensions.cs b/zero.Core/Extensions/RavenDocumentStoreExtensions.cs
index 7abbe458..6662179d 100644
--- a/zero.Core/Extensions/RavenDocumentStoreExtensions.cs
+++ b/zero.Core/Extensions/RavenDocumentStoreExtensions.cs
@@ -110,48 +110,5 @@ namespace zero.Core.Extensions
return store;
}
-
-
- ///
- /// Reserves a key cluster-wide
- ///
- public static async Task ReserveAsync(this IDocumentStore store, string key, string value = null)
- {
- if (String.IsNullOrWhiteSpace(key))
- {
- return false;
- }
- if (value == null)
- {
- value = key;
- }
- var operation = new PutCompareExchangeValueOperation(key, value, 0);
- CompareExchangeResult result = await store.Operations.SendAsync(operation).ConfigureAwait(false);
- return result.Successful;
- }
-
-
- ///
- /// Removes a cluster-wide key reservation
- ///
- public static async Task RemoveReservationAsync(this IDocumentStore store, string key)
- {
- if (!String.IsNullOrWhiteSpace(key))
- {
- return false;
- }
-
- CompareExchangeValue readResult = store.Operations.Send(new GetCompareExchangeValueOperation(key));
-
- if (readResult == null)
- {
- return false;
- }
-
- DeleteCompareExchangeValueOperation operation = new DeleteCompareExchangeValueOperation(key, readResult.Index);
-
- CompareExchangeResult result = await store.Operations.SendAsync(operation).ConfigureAwait(false);
- return result.Successful;
- }
}
}
diff --git a/zero.Core/Extensions/StringExtensions.cs b/zero.Core/Extensions/StringExtensions.cs
index 86bec22a..77ef1bde 100644
--- a/zero.Core/Extensions/StringExtensions.cs
+++ b/zero.Core/Extensions/StringExtensions.cs
@@ -1,11 +1,28 @@
using System;
using System.Globalization;
using System.Linq;
+using System.Text.RegularExpressions;
namespace zero.Core.Extensions
{
public static class StringExtensions
{
+ const string SPACE = " ";
+
+ static Regex replaceMultipleSpacesRegex { get; } = new Regex("[ ]{2,}", 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;
diff --git a/zero.Core/Identity/Base32.cs b/zero.Core/Identity/Base32.cs
new file mode 100644
index 00000000..46f3057a
--- /dev/null
+++ b/zero.Core/Identity/Base32.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Text;
+
+namespace zero.Core.Identity
+{
+ // This is a re-implementation of ASP.NET Core Base32, as they have marked it as internal
+ // by using this we can create user security stamps on the fly and don't need
+ // see: https://github.com/dotnet/aspnetcore/blob/release/5.0/src/Identity/Extensions.Core/src/Base32.cs
+ public static class Base32
+ {
+ private static readonly string _base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+
+ public static string ToBase32(byte[] input)
+ {
+ if (input == null)
+ {
+ throw new ArgumentNullException(nameof(input));
+ }
+
+ StringBuilder sb = new StringBuilder();
+ for (int offset = 0; offset < input.Length;)
+ {
+ byte a, b, c, d, e, f, g, h;
+ int numCharsToOutput = GetNextGroup(input, ref offset, out a, out b, out c, out d, out e, out f, out g, out h);
+
+ sb.Append((numCharsToOutput >= 1) ? _base32Chars[a] : '=');
+ sb.Append((numCharsToOutput >= 2) ? _base32Chars[b] : '=');
+ sb.Append((numCharsToOutput >= 3) ? _base32Chars[c] : '=');
+ sb.Append((numCharsToOutput >= 4) ? _base32Chars[d] : '=');
+ sb.Append((numCharsToOutput >= 5) ? _base32Chars[e] : '=');
+ sb.Append((numCharsToOutput >= 6) ? _base32Chars[f] : '=');
+ sb.Append((numCharsToOutput >= 7) ? _base32Chars[g] : '=');
+ sb.Append((numCharsToOutput >= 8) ? _base32Chars[h] : '=');
+ }
+
+ return sb.ToString();
+ }
+
+ public static byte[] FromBase32(string input)
+ {
+ if (input == null)
+ {
+ throw new ArgumentNullException(nameof(input));
+ }
+ input = input.TrimEnd('=').ToUpperInvariant();
+ if (input.Length == 0)
+ {
+ return new byte[0];
+ }
+
+ var output = new byte[input.Length * 5 / 8];
+ var bitIndex = 0;
+ var inputIndex = 0;
+ var outputBits = 0;
+ var outputIndex = 0;
+ while (outputIndex < output.Length)
+ {
+ var byteIndex = _base32Chars.IndexOf(input[inputIndex]);
+ if (byteIndex < 0)
+ {
+ throw new FormatException();
+ }
+
+ var bits = Math.Min(5 - bitIndex, 8 - outputBits);
+ output[outputIndex] <<= bits;
+ output[outputIndex] |= (byte)(byteIndex >> (5 - (bitIndex + bits)));
+
+ bitIndex += bits;
+ if (bitIndex >= 5)
+ {
+ inputIndex++;
+ bitIndex = 0;
+ }
+
+ outputBits += bits;
+ if (outputBits >= 8)
+ {
+ outputIndex++;
+ outputBits = 0;
+ }
+ }
+ return output;
+ }
+
+ // returns the number of bytes that were output
+ private static int GetNextGroup(byte[] input, ref int offset, out byte a, out byte b, out byte c, out byte d, out byte e, out byte f, out byte g, out byte h)
+ {
+ uint b1, b2, b3, b4, b5;
+
+ int retVal;
+ switch (input.Length - offset)
+ {
+ case 1: retVal = 2; break;
+ case 2: retVal = 4; break;
+ case 3: retVal = 5; break;
+ case 4: retVal = 7; break;
+ default: retVal = 8; break;
+ }
+
+ b1 = (offset < input.Length) ? input[offset++] : 0U;
+ b2 = (offset < input.Length) ? input[offset++] : 0U;
+ b3 = (offset < input.Length) ? input[offset++] : 0U;
+ b4 = (offset < input.Length) ? input[offset++] : 0U;
+ b5 = (offset < input.Length) ? input[offset++] : 0U;
+
+ a = (byte)(b1 >> 3);
+ b = (byte)(((b1 & 0x07) << 2) | (b2 >> 6));
+ c = (byte)((b2 >> 1) & 0x1f);
+ d = (byte)(((b2 & 0x01) << 4) | (b3 >> 4));
+ e = (byte)(((b3 & 0x0f) << 1) | (b4 >> 7));
+ f = (byte)((b4 >> 2) & 0x1f);
+ g = (byte)(((b4 & 0x3) << 3) | (b5 >> 5));
+ h = (byte)(b5 & 0x1f);
+
+ return retVal;
+ }
+ }
+}
diff --git a/zero.Core/Identity/RavenUserStore(TUser).cs b/zero.Core/Identity/RavenUserStore(TUser).cs
index ff584159..e0b73b7b 100644
--- a/zero.Core/Identity/RavenUserStore(TUser).cs
+++ b/zero.Core/Identity/RavenUserStore(TUser).cs
@@ -47,24 +47,32 @@ namespace zero.Core.Identity
///
protected virtual IRavenQueryable ScopeQuery(IRavenQueryable query) => query;
- //
+ ///
/// Get the database to operate on for this store
///
protected virtual string GetDatabase() => Store.ResolvedDatabase;
- //
+ ///
/// Get a new document store session
///
protected virtual IAsyncDocumentSession GetSession() => Store.OpenAsyncSession(GetDatabase());
+ ///
+ /// Whether an email is already reserved
+ ///
+ protected virtual async Task IsEmailReserved(IAsyncDocumentSession session, TUser user, CancellationToken cancellationToken = default)
+ {
+ TUser existingUser = await ScopeQuery(session.Query()).FirstOrDefaultAsync(x => x.Email == user.Email, cancellationToken);
+ return existingUser != null && existingUser.Id != user.Id;
+ }
+
///
public async Task CreateAsync(TUser user, CancellationToken cancellationToken)
{
using IAsyncDocumentSession session = GetSession();
- // try to reserve the key for the new user
- if (!await Store.Raven.ReserveAsync(GetReservationKey(user), user.Id))
+ if (await IsEmailReserved(session, user))
{
return IdentityResult.Failed(new IdentityError
{
@@ -74,21 +82,7 @@ namespace zero.Core.Identity
}
await session.StoreAsync(user, cancellationToken);
-
- // Because this a a cluster-wide operation due to compare/exchange tokens,
- // we need to save changes here; if we can't store the user,
- // we need to roll back the email reservation.
- try
- {
- await session.SaveChangesAsync(cancellationToken);
- }
- catch (Exception)
- {
- // The compare/exchange email reservation is cluster-wide, outside of the session scope.
- // We need to manually roll it back.
- await Store.Raven.RemoveReservationAsync(GetReservationKey(user));
- throw;
- }
+ await session.SaveChangesAsync(cancellationToken);
return IdentityResult.Success;
}
@@ -97,11 +91,6 @@ namespace zero.Core.Identity
///
public async Task DeleteAsync(TUser user, CancellationToken cancellationToken)
{
- // Remove the cluster-wide compare/exchange key.
- await Store.Raven.RemoveReservationAsync(GetReservationKey(user));
-
- // Delete the user and save it. We must save it because deleting is a cluster-wide operation.
- // Only if the deletion succeeds will we remove the cluster-wide compare/exchange key.
using IAsyncDocumentSession session = GetSession();
TUser source = await session.LoadAsync(user.Id, cancellationToken);
@@ -129,20 +118,13 @@ namespace zero.Core.Identity
});
}
- if (source.Email != user.Email)
+ if (source.Email != user.Email && await IsEmailReserved(session, user))
{
- // try to reserve the key for the new user
- if (!await Store.Raven.ReserveAsync(GetReservationKey(user), user.Id))
+ return IdentityResult.Failed(new IdentityError
{
- return IdentityResult.Failed(new IdentityError
- {
- Code = "DuplicateEmail",
- Description = $"The email address {user.Email} is already taken."
- });
- }
-
- // Remove the cluster-wide compare/exchange key.
- await Store.Raven.RemoveReservationAsync(GetReservationKey(source));
+ Code = "DuplicateEmail",
+ Description = $"The email address {user.Email} is already taken."
+ });
}
}
diff --git a/zero.Core/Mails/IMailSender.cs b/zero.Core/Mails/IMailSender.cs
index 4cbc8e7a..da99d4e8 100644
--- a/zero.Core/Mails/IMailSender.cs
+++ b/zero.Core/Mails/IMailSender.cs
@@ -1,19 +1,12 @@
-using System.Threading;
+using System.Net.Mail;
+using System.Threading;
using System.Threading.Tasks;
-using zero.Core.Entities;
namespace zero.Core.Mails
{
public interface IMailSender
{
- ///
- /// Send a mail
- ///
- //Task Create(IMailTemplate template);
-
- ///
- /// Send a mail
- ///
- //Task Send(Mail message, CancellationToken token = default);
+ ///
+ Task Send(Mail message, CancellationToken token = default);
}
}
diff --git a/zero.Core/Mails/LoggerMailSender.cs b/zero.Core/Mails/LoggerMailSender.cs
new file mode 100644
index 00000000..336891f7
--- /dev/null
+++ b/zero.Core/Mails/LoggerMailSender.cs
@@ -0,0 +1,30 @@
+using Microsoft.Extensions.Logging;
+using System.Net.Mail;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace zero.Core.Mails
+{
+ ///
+ /// Default implementation of an IMailSender which sends the mail to the attached logger
+ /// and therefore not using the SMTP channel.
+ /// Implementing real mail sending is up to the consuming application.
+ ///
+ public class LoggerMailSender : IMailSender
+ {
+ protected ILogger Logger { get; set; }
+
+
+ public LoggerMailSender(ILogger logger)
+ {
+ Logger = logger;
+ }
+
+
+ public Task Send(Mail message, CancellationToken token = default)
+ {
+ Logger.LogInformation("Mail to {to}. Subject: {subject}", message.To[0].Address, message.Subject);
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/zero.Core/Mails/Mail.cs b/zero.Core/Mails/Mail.cs
index 978c3534..6ab14522 100644
--- a/zero.Core/Mails/Mail.cs
+++ b/zero.Core/Mails/Mail.cs
@@ -11,6 +11,8 @@ namespace zero.Core.Mails
{
public IMailTemplate Template { get; set; }
- public Dictionary Placeholders { get; private set; } = new Dictionary();
+ public Dictionary Placeholders { get; set; } = new Dictionary();
+
+ public Dictionary Metadata { get; set; } = new Dictionary();
}
}
diff --git a/zero.Core/Mails/MailProvider.cs b/zero.Core/Mails/MailProvider.cs
new file mode 100644
index 00000000..d4d4b192
--- /dev/null
+++ b/zero.Core/Mails/MailProvider.cs
@@ -0,0 +1,142 @@
+using Microsoft.Extensions.Logging;
+using System.Net.Mail;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using zero.Core.Collections;
+using zero.Core.Entities;
+using zero.Core.Extensions;
+
+namespace zero.Core.Mails
+{
+ public class MailProvider : IMailProvider
+ {
+ protected IMailTemplatesCollection Collection { get; set; }
+
+ protected ILogger Logger { get; set; }
+
+ protected IZeroContext Zero { get; set; }
+
+ protected IMailSender MailSender { get; set; }
+
+ protected Regex PlaceholderRegex { get; set; }
+
+ private Encoding encoding = Encoding.UTF8;
+
+
+ public MailProvider(IZeroContext zero, IMailTemplatesCollection collection, ILogger logger, IMailSender mailSender)
+ {
+ Zero = zero;
+ Collection = collection;
+ Logger = logger;
+ MailSender = mailSender;
+ PlaceholderRegex = new Regex("{([\\w-_.]+)}", RegexOptions.IgnoreCase);
+ }
+
+
+ ///
+ public virtual async Task Create(string mailTemplateKey, CancellationToken token = default)
+ {
+ IMailTemplate template = await GetMailTemplate(mailTemplateKey);
+
+ if (template == null)
+ {
+ Logger.LogError("Could not find a mail template with the key {key}", mailTemplateKey);
+ return null;
+ }
+
+ Mail mail = new Mail();
+
+ mail.Template = template;
+
+ // get sender from template or fall back to application
+ mail.From = new MailAddress(template.SenderEmail.Or(Zero.Application.Email), template.SenderName.Or(Zero.Application.FullName), encoding);
+ mail.Sender = mail.From;
+ mail.ReplyToList.Add(mail.From);
+
+ // cc + bcc (multiple addresses are separated with commas
+ if (!template.Cc.IsNullOrWhiteSpace())
+ {
+ mail.CC.Add(template.Cc);
+ }
+ if (!template.Bcc.IsNullOrWhiteSpace())
+ {
+ mail.Bcc.Add(template.Bcc);
+ }
+
+ // recipient
+ if (!template.RecipientEmail.IsNullOrWhiteSpace())
+ {
+ mail.To.Add(template.RecipientEmail);
+ }
+
+ // subject
+ mail.Subject = template.Subject;
+ mail.SubjectEncoding = encoding;
+
+ // body
+ mail.Body = mail.Template.Body;
+ mail.IsBodyHtml = true;
+ mail.BodyEncoding = encoding;
+
+ return mail;
+ }
+
+
+ ///
+ public virtual async Task Send(Mail message, CancellationToken token = default)
+ {
+ await Send(message, MailSender, token);
+ }
+
+
+ ///
+ public virtual async Task Send(Mail message, IMailSender sender, CancellationToken token = default)
+ {
+ await sender.Send(message, token);
+ }
+
+
+ ///
+ protected virtual async Task GetMailTemplate(string key)
+ {
+ return await Collection.GetByKey(key);
+ }
+
+
+ //protected Mail ReplacePlaceholders(Mail message)
+ //{
+
+
+ // foreach ((string key, string value) in message.Placeholders)
+ // {
+
+ // }
+ //}
+
+
+ //protected string ReplacePlaceholders(string text)
+ //{
+ // MatchCollection matches = PlaceholderRegex.Matches(text);
+
+ // foreach (Match match in matches)
+ // {
+ // text.
+ // }
+ //}
+ }
+
+
+ public interface IMailProvider
+ {
+ ///
+ Task Create(string mailTemplateKey, CancellationToken token = default);
+
+ ///
+ Task Send(Mail message, CancellationToken token = default);
+
+ ///
+ Task Send(Mail message, IMailSender sender, CancellationToken token = default);
+ }
+}
diff --git a/zero.Core/Mails/MailSender.cs b/zero.Core/Mails/MailSender.cs
deleted file mode 100644
index 4341a877..00000000
--- a/zero.Core/Mails/MailSender.cs
+++ /dev/null
@@ -1,121 +0,0 @@
-using Microsoft.Extensions.Logging;
-using System;
-using System.Net.Mail;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using zero.Core.Api;
-using zero.Core.Collections;
-using zero.Core.Entities;
-using zero.Core.Extensions;
-
-namespace zero.Core.Mails
-{
- public class MailSender : IMailSender
- {
- protected IMailTemplatesCollection Collection { get; set; }
-
- protected SmtpClient Client { get; set; }
-
- protected ILogger Logger { get; set; }
-
- protected IZeroContext Zero { get; set; }
-
- private Encoding encoding = Encoding.UTF8;
-
-
- public MailSender(IZeroContext zero, IMailTemplatesCollection collection, ILogger logger)
- {
- Zero = zero;
- Collection = collection;
- Logger = logger;
- Client = new SmtpClient();
- }
-
-
- ///
- //public virtual async Task Create(string mailTemplateKey, CancellationToken token = default)
- //{
- // IMailTemplate template = await GetMailTemplate(mailTemplateKey);
-
- // if (template == null)
- // {
- // Logger.LogError("Could not find a mail template with the key {key}", mailTemplateKey);
- // return null;
- // }
-
- // MailMessage message = new MailMessage();
-
- // // get sender from template or fall back to application
- // message.From = message.Sender;
- // message.Sender = new MailAddress(template.SenderEmail.Or(Zero.App.Email), template.SenderName.Or(Zero.App.FullName), encoding);
- // message.ReplyToList.Add(message.From);
-
- // // cc + bcc (multiple addresses are separated with commas
- // if (!template.Cc.IsNullOrWhiteSpace())
- // {
- // message.CC.Add(template.Cc);
- // }
- // if (!template.Bcc.IsNullOrWhiteSpace())
- // {
- // message.Bcc.Add(template.Bcc);
- // }
-
- // // recipient
- // if (!template.RecipientEmail.IsNullOrWhiteSpace())
- // {
- // message.To.Add(template.RecipientEmail);
- // }
-
- // // subject
- // message.Subject = template.Subject;
- // message.SubjectEncoding = encoding;
- //}
-
-
- ///
- public virtual async Task Send(string mailTemplateKey, Mail message, CancellationToken token = default)
- {
- message.Template = await GetMailTemplate(mailTemplateKey);
-
- if (message.Template == null)
- {
- Logger.LogError("Could not find a mail template with the key {key}", mailTemplateKey);
- return false;
- }
-
- return await Send(message, token);
- }
-
-
- ///
- public virtual async Task Send(Mail message, CancellationToken token = default)
- {
- await Task.Delay(0);
- return true;
- }
-
-
- ///
- public virtual async Task Send(MailMessage message, CancellationToken token = default)
- {
- try
- {
- await Client.SendMailAsync(message, token);
- return true;
- }
- catch (Exception ex)
- {
- Logger.LogError(ex, "Could not send mail message via SmtpClient");
- return false;
- }
- }
-
-
- ///
- protected virtual async Task GetMailTemplate(string key)
- {
- return await Collection.GetByKey(key);
- }
- }
-}
diff --git a/zero.Core/zero.Core.csproj b/zero.Core/zero.Core.csproj
index 5e0d4f65..5acac1d6 100644
--- a/zero.Core/zero.Core.csproj
+++ b/zero.Core/zero.Core.csproj
@@ -20,10 +20,10 @@
-
-
+
+
-
+
diff --git a/zero.Web/Defaults/ZeroBackofficePlugin.cs b/zero.Web/Defaults/ZeroBackofficePlugin.cs
index 34b3bdae..db94b4d8 100644
--- a/zero.Web/Defaults/ZeroBackofficePlugin.cs
+++ b/zero.Web/Defaults/ZeroBackofficePlugin.cs
@@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection;
using zero.Core.Api;
using zero.Core.Collections;
using zero.Core.Entities;
+using zero.Core.Mails;
using zero.Core.Messages;
using zero.Core.Options;
using zero.Core.Plugins;
@@ -104,6 +105,9 @@ namespace zero.Web.Defaults
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
services.AddScoped();
}
}
diff --git a/zero.Web/zero.Web.csproj b/zero.Web/zero.Web.csproj
index 7de1a05b..816d28bf 100644
--- a/zero.Web/zero.Web.csproj
+++ b/zero.Web/zero.Web.csproj
@@ -11,9 +11,9 @@
-
+
-
+