From f697330b12931d6adaadfae608b6b9056f9d89d5 Mon Sep 17 00:00:00 2001 From: Tobias Klika Date: Thu, 26 Nov 2020 16:13:00 +0100 Subject: [PATCH] mailings --- zero.Core/Extensions/HttpContextExtensions.cs | 30 +++- .../RavenDocumentStoreExtensions.cs | 43 ------ zero.Core/Extensions/StringExtensions.cs | 17 +++ zero.Core/Identity/Base32.cs | 118 +++++++++++++++ zero.Core/Identity/RavenUserStore(TUser).cs | 54 +++---- zero.Core/Mails/IMailSender.cs | 15 +- zero.Core/Mails/LoggerMailSender.cs | 30 ++++ zero.Core/Mails/Mail.cs | 4 +- zero.Core/Mails/MailProvider.cs | 142 ++++++++++++++++++ zero.Core/Mails/MailSender.cs | 121 --------------- zero.Core/zero.Core.csproj | 6 +- zero.Web/Defaults/ZeroBackofficePlugin.cs | 4 + zero.Web/zero.Web.csproj | 4 +- 13 files changed, 370 insertions(+), 218 deletions(-) create mode 100644 zero.Core/Identity/Base32.cs create mode 100644 zero.Core/Mails/LoggerMailSender.cs create mode 100644 zero.Core/Mails/MailProvider.cs delete mode 100644 zero.Core/Mails/MailSender.cs 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 @@ - + - +