diff --git a/zero.Raven/Operations/RavenOperations.Delete.cs b/zero.Raven/Operations/RavenOperations.Delete.cs
index 5bd164e4..6c2b8830 100644
--- a/zero.Raven/Operations/RavenOperations.Delete.cs
+++ b/zero.Raven/Operations/RavenOperations.Delete.cs
@@ -3,8 +3,15 @@
public partial class RavenOperations : IRavenOperations
{
///
- public virtual async Task> Delete(T model) where T : ZeroIdEntity, new()
+ public virtual Task> Delete(T model) where T : ZeroIdEntity, new()
+ => Delete(model.Id);
+
+
+ ///
+ public virtual async Task> Delete(string id) where T : ZeroIdEntity, new()
{
+ T model = await Load(id);
+
if (model == null)
{
return Result.Fail("@errors.ondelete.idnotfound");
@@ -17,7 +24,7 @@ public partial class RavenOperations : IRavenOperations
return instruction.Result;
}
- Session.Delete(model);
+ Session.Delete(model);
await Session.SaveChangesAsync();
if (InterceptorBlocker == null)
{
diff --git a/zero.Raven/Operations/RavenOperations.cs b/zero.Raven/Operations/RavenOperations.cs
index 501e71f0..b7548055 100644
--- a/zero.Raven/Operations/RavenOperations.cs
+++ b/zero.Raven/Operations/RavenOperations.cs
@@ -238,6 +238,11 @@ public interface IRavenOperations
/// Deletes an entity
///
Task> Delete(T model) where T : ZeroIdEntity, new();
+
+ ///
+ /// Deletes an entity
+ ///
+ Task> Delete(string id) where T : ZeroIdEntity, new();
///
/// Loads all children for an entity
diff --git a/zero/Extensions/StringExtensions.cs b/zero/Extensions/StringExtensions.cs
index 7d9327f3..497cc96c 100644
--- a/zero/Extensions/StringExtensions.cs
+++ b/zero/Extensions/StringExtensions.cs
@@ -212,4 +212,22 @@ public static class StringExtensions
return builder.ToString();
}
+
+
+ public static string FormatTwoFactorAuthenticationKey(this string unformattedKey)
+ {
+ var result = new StringBuilder();
+ int currentPosition = 0;
+ while (currentPosition + 4 < unformattedKey.Length)
+ {
+ result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
+ currentPosition += 4;
+ }
+ if (currentPosition < unformattedKey.Length)
+ {
+ result.Append(unformattedKey.AsSpan(currentPosition));
+ }
+
+ return result.ToString().ToLowerInvariant();
+ }
}
diff --git a/zero/Identity/Models/TwoFactorKey.cs b/zero/Identity/Models/TwoFactorKey.cs
new file mode 100644
index 00000000..f78205ee
--- /dev/null
+++ b/zero/Identity/Models/TwoFactorKey.cs
@@ -0,0 +1,53 @@
+using System.Security.Claims;
+
+namespace zero.Identity;
+
+public class TwoFactorKey
+{
+ public string UserEmail { get; init; }
+
+ public string UnformattedKey { get; init; }
+
+ public string FormattedKey { get; init; }
+
+ public string AuthenticatorUrl { get; init; }
+
+ public string QrCodeSvg { get; set; }
+
+ public TwoFactorKeyOptions Options { get; init; }
+}
+
+public class TwoFactorKeyOptions
+{
+ public string Issuer { get; set; }
+
+ public TwoFactorKeyLength Digits { get; set; } = TwoFactorKeyLength.Six;
+
+ public TwoFactorKeyAlgorithm Algorithm { get; set; } = TwoFactorKeyAlgorithm.SHA1;
+
+ public TwoFactorKeyRefreshPeriod Period { get; set; } = TwoFactorKeyRefreshPeriod.ThirtySeconds;
+}
+
+public enum TwoFactorKeyLength
+{
+ Six = 6,
+ Seven = 7,
+ Eight = 8
+}
+
+public enum TwoFactorKeyAlgorithm
+{
+ // ReSharper disable once InconsistentNaming
+ SHA1 = 0,
+ // ReSharper disable once InconsistentNaming
+ SHA256 = 1,
+ // ReSharper disable once InconsistentNaming
+ SHA512 = 2
+}
+
+public enum TwoFactorKeyRefreshPeriod
+{
+ FifteenSeconds = 15,
+ ThirtySeconds = 30,
+ SixtySeconds = 60
+}
\ No newline at end of file
diff --git a/zero/Identity/UserManagerExtensions.cs b/zero/Identity/UserManagerExtensions.cs
new file mode 100644
index 00000000..c773607e
--- /dev/null
+++ b/zero/Identity/UserManagerExtensions.cs
@@ -0,0 +1,104 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using zero.Rendering.QrCode;
+
+namespace zero.Identity;
+
+public static class UserManagerExtensions
+{
+ private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits={3}&algorithm={4}&period={5}";
+
+
+ ///
+ /// Adds an implementation of identity information stores.
+ ///
+ public static Task GetTwoFactorKey(this UserManager userManager, T user, string issuer)
+ where T : class => GetTwoFactorKey(userManager, user, new TwoFactorKeyOptions() { Issuer = issuer });
+
+
+ ///
+ /// Adds an implementation of identity information stores.
+ ///
+ public static async Task GetTwoFactorKey(this UserManager userManager, T user, TwoFactorKeyOptions options)
+ where T : class
+ {
+ string unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
+ string email = await userManager.GetEmailAsync(user);
+
+ if (unformattedKey.IsNullOrEmpty())
+ {
+ await userManager.ResetAuthenticatorKeyAsync(user);
+ unformattedKey = await userManager.GetAuthenticatorKeyAsync(user);
+ }
+
+ string url = string.Format(
+ CultureInfo.InvariantCulture,
+ AuthenticatorUriFormat,
+ UrlEncoder.Default.Encode(options.Issuer),
+ UrlEncoder.Default.Encode(email),
+ unformattedKey,
+ (int)options.Digits,
+ options.Algorithm.ToString(),
+ (int)options.Period);
+
+ QrCode qr = QrCode.EncodeText(url, QrCode.Ecc.Medium);
+ string svg = qr.ToSvgString(0);
+
+ return new TwoFactorKey()
+ {
+ UserEmail = email,
+ UnformattedKey = unformattedKey,
+ FormattedKey = FormatTwoFactorAuthenticationKey(unformattedKey),
+ AuthenticatorUrl = url,
+ QrCodeSvg = svg,
+ Options = options
+ };
+ }
+
+
+ public static async Task> SetupTwoFactorAuth(this UserManager userManager, T user,
+ string inputCode, int recoveryCodesToGenerate = 10) where T : class
+ {
+ string token = inputCode.Replace(" ", string.Empty).Replace("-", string.Empty);
+ bool is2FaTokenValid = await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, token);
+
+ if (!is2FaTokenValid)
+ {
+ return Result.Fail();
+ }
+
+ // enable two-factor auth for account
+ await userManager.SetTwoFactorEnabledAsync(user, true);
+
+ // generate recovery codes
+ if (await userManager.CountRecoveryCodesAsync(user) == 0)
+ {
+ IEnumerable recoveryCodes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, recoveryCodesToGenerate);
+ return Result.Success(recoveryCodes.ToArray());
+ }
+
+ return Result.Success(Array.Empty());
+ }
+
+
+ private static string FormatTwoFactorAuthenticationKey(string unformattedKey)
+ {
+ var result = new StringBuilder();
+ int currentPosition = 0;
+ while (currentPosition + 4 < unformattedKey.Length)
+ {
+ result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
+ currentPosition += 4;
+ }
+ if (currentPosition < unformattedKey.Length)
+ {
+ result.Append(unformattedKey.AsSpan(currentPosition));
+ }
+
+ return result.ToString().ToLowerInvariant();
+ }
+}
\ No newline at end of file
diff --git a/zero/Media/MediaCreator.cs b/zero/Media/MediaCreator.cs
index 563a33be..04481bcb 100644
--- a/zero/Media/MediaCreator.cs
+++ b/zero/Media/MediaCreator.cs
@@ -33,7 +33,7 @@ public class MediaCreator : IMediaCreator
if (!isImage && !isDocument)
{
// TODO error
- return Result.Fail("ERROR");
+ return Result.Fail("This file type is not supported");
}
Media model = new();
@@ -65,22 +65,25 @@ public class MediaCreator : IMediaCreator
string extension = Path.GetExtension(model.Path);
- foreach ((string key, ResizeOptions opts) in Options.Thumbnails)
+ if (Options.Thumbnails != null)
{
- Image imageFrame = image.Frames.Count > 1 ? image.Frames.CloneFrame(0) : image.Clone();
- imageFrame.Mutate(x => x.Resize(opts));
+ foreach ((string key, ResizeOptions opts) in Options.Thumbnails)
+ {
+ Image imageFrame = image.Frames.Count > 1 ? image.Frames.CloneFrame(0) : image.Clone();
+ imageFrame.Mutate(x => x.Resize(opts));
- using MemoryStream stream = new();
- await imageFrame.SaveAsync(stream, new PngEncoder(), cancellationToken);
+ using MemoryStream stream = new();
+ await imageFrame.SaveAsync(stream, new PngEncoder(), cancellationToken);
- stream.Position = 0;
+ stream.Position = 0;
- string thumbFilename = normalizedFilename.TrimEnd(extension) + "." + Safenames.File(key) + ".png";
- string path = directory + '/' + thumbFilename;
+ string thumbFilename = normalizedFilename.TrimEnd(extension) + "." + Safenames.File(key) + ".png";
+ string path = directory + '/' + thumbFilename;
- await FileSystem.CreateFile(path, stream, cancellationToken: cancellationToken);
+ await FileSystem.CreateFile(path, stream, cancellationToken: cancellationToken);
- model.Thumbnails[key] = path;
+ model.Thumbnails[key] = path;
+ }
}
}
diff --git a/zero/Media/MediaExtensions.cs b/zero/Media/MediaExtensions.cs
index dc8e3004..7d9777b7 100644
--- a/zero/Media/MediaExtensions.cs
+++ b/zero/Media/MediaExtensions.cs
@@ -31,10 +31,11 @@ public static class MediaExtensions
return values;
}
+ public static string Resize(this Media media, string preset) => media?.Path.Resize(preset, media?.Metadata?.FocalPoint, true);
+
+ public static string Resize(this string path, string preset, bool isZeroMedia) => path.Resize(preset, null, isZeroMedia);
- public static string Resize(this string path, string preset) => path.Resize(preset, null);
-
- public static string Resize(this string path, string preset, MediaFocalPoint focalPoint = null)
+ public static string Resize(this string path, string preset, MediaFocalPoint focalPoint = null, bool isZeroMedia = false)
{
if (path.IsNullOrEmpty())
{
@@ -49,6 +50,14 @@ public static class MediaExtensions
return String.Join('/', parts);
}
+ if (isZeroMedia)
+ {
+ // TODO this is bullshit because we need to get the base path from options
+ return parts[1] == "media" || parts[0] == "media"
+ ? String.Join('/', parts)
+ : ("/media" + String.Join('/', parts).EnsureStartsWith('/'));
+ }
+
return String.Join('/', parts).EnsureStartsWith('/');
}
diff --git a/zero/Media/MediaManagement.cs b/zero/Media/MediaManagement.cs
index 094482a8..2da20e94 100644
--- a/zero/Media/MediaManagement.cs
+++ b/zero/Media/MediaManagement.cs
@@ -88,7 +88,7 @@ public class MediaManagement : IMediaManagement
///
- public virtual async Task> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default)
+ public virtual async Task> UploadFile(Stream fileStream, string filename, string folderId = null, Action onBeforeSave = null, CancellationToken cancellationToken = default)
{
Result result = await Creator.UploadFile(fileStream, filename, folderId, cancellationToken);
@@ -97,6 +97,8 @@ public class MediaManagement : IMediaManagement
return result;
}
+ onBeforeSave?.Invoke(result.Model);
+
return await Db.Create(result.Model);
}
@@ -176,7 +178,7 @@ public interface IMediaManagement
///
/// Uploads a file and persists it
///
- Task> UploadFile(Stream fileStream, string filename, string folderId = null, CancellationToken cancellationToken = default);
+ Task> UploadFile(Stream fileStream, string filename, string folderId = null, Action onBeforeSave = null, CancellationToken cancellationToken = default);
///
/// Get a media folder by id
diff --git a/zero/Media/MediaManagementExtensions.cs b/zero/Media/MediaManagementExtensions.cs
index b701b7e5..3fc4f3ef 100644
--- a/zero/Media/MediaManagementExtensions.cs
+++ b/zero/Media/MediaManagementExtensions.cs
@@ -36,20 +36,20 @@ namespace zero.Media
///
/// Uploads a file and persists it
///
- public static async Task> UploadFile(this IMediaManagement media, IFormFile formFile, string folderId = null, CancellationToken cancellationToken = default)
+ public static async Task> UploadFile(this IMediaManagement media, IFormFile formFile, string folderId = null, Action onBeforeSave = null, CancellationToken cancellationToken = default)
{
using Stream stream = formFile.OpenReadStream();
- return await media.UploadFile(stream, formFile.FileName, folderId, cancellationToken);
+ return await media.UploadFile(stream, formFile.FileName, folderId, onBeforeSave, cancellationToken);
}
///
/// Uploads a file and persists it
///
- public static async Task> UploadFile(this IMediaManagement media, byte[] fileBytes, string filename, string folderId = null, CancellationToken cancellationToken = default)
+ public static async Task> UploadFile(this IMediaManagement media, byte[] fileBytes, string filename, string folderId = null, Action onBeforeSave = null, CancellationToken cancellationToken = default)
{
using Stream stream = new MemoryStream(fileBytes);
- return await media.UploadFile(stream, filename, folderId, cancellationToken);
+ return await media.UploadFile(stream, filename, folderId, onBeforeSave, cancellationToken);
}
diff --git a/zero/Media/ZeroMediaModule.cs b/zero/Media/ZeroMediaModule.cs
index 5508a535..67cec15c 100644
--- a/zero/Media/ZeroMediaModule.cs
+++ b/zero/Media/ZeroMediaModule.cs
@@ -4,6 +4,8 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using SixLabors.ImageSharp.Processing;
using System.IO;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
using SixLabors.ImageSharp.Web.Caching;
using SixLabors.ImageSharp.Web.DependencyInjection;
using zero.Media.ImageSharp;
@@ -53,4 +55,9 @@ internal class ZeroMediaModule : ZeroModule
// raven.Indexes.Add();
// });
}
+
+ public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider)
+ {
+ app.UseImageSharp();
+ }
}
\ No newline at end of file
diff --git a/zero/Validation/ModelStateDictionaryExtensions.cs b/zero/Validation/ModelStateDictionaryExtensions.cs
index 2e840c94..85d4e3e5 100644
--- a/zero/Validation/ModelStateDictionaryExtensions.cs
+++ b/zero/Validation/ModelStateDictionaryExtensions.cs
@@ -1,4 +1,5 @@
using FluentValidation.Results;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace zero.Validation;
@@ -22,7 +23,7 @@ public static class ModelStateDictionaryExtensions
if (propertyName != null && removePrefixes != null && removePrefixes.Any())
{
- foreach (var removePrefix in removePrefixes)
+ foreach (string removePrefix in removePrefixes)
{
if (propertyName.StartsWith(removePrefix, StringComparison.InvariantCultureIgnoreCase))
{
@@ -32,7 +33,7 @@ public static class ModelStateDictionaryExtensions
}
}
- string key = prefix.IsNullOrEmpty() ? propertyName : (propertyName.IsNullOrEmpty() ? String.Empty : prefix + DOT + propertyName);
+ string key = prefix.IsNullOrEmpty() ? propertyName : (propertyName.IsNullOrEmpty() ? string.Empty : prefix + DOT + propertyName);
string message = error.ErrorMessage;
bool found = formProperties.Contains(key, StringComparer.InvariantCultureIgnoreCase);
@@ -40,17 +41,79 @@ public static class ModelStateDictionaryExtensions
if (!found && aliases != null && aliases.ContainsKey(propertyName))
{
key = aliases[propertyName];
- key = prefix.IsNullOrEmpty() ? key : (key.IsNullOrEmpty() ? String.Empty : prefix + DOT + key);
+ key = prefix.IsNullOrEmpty() ? key : (key.IsNullOrEmpty() ? string.Empty : prefix + DOT + key);
found = formProperties.Contains(key, StringComparer.InvariantCultureIgnoreCase);
}
// move property errors to model if they are not part of the form
if (!found && convertUnresolvedPropertiesToModel)
{
- key = String.Empty;
+ key = string.Empty;
}
modelState.AddModelError(key, message);
}
}
+
+
+ public static void AddToModelState(this Result result, ModelStateDictionary modelState, bool convertUnresolvedPropertiesToModel = true, string prefix = "Form", Dictionary aliases = default, IEnumerable removePrefixes = default)
+ {
+ if (result == null || result.IsSuccess)
+ {
+ return;
+ }
+
+ string[] formProperties = modelState.Keys.ToArray();
+
+ foreach (var error in result.Errors)
+ {
+ string propertyName = error.Property;
+
+ if (propertyName != null && removePrefixes != null && removePrefixes.Any())
+ {
+ foreach (string removePrefix in removePrefixes)
+ {
+ if (propertyName.StartsWith(removePrefix, StringComparison.InvariantCultureIgnoreCase))
+ {
+ propertyName = propertyName.Substring(removePrefix.Length);
+ break;
+ }
+ }
+ }
+
+ string key = prefix.IsNullOrEmpty() ? propertyName : (propertyName.IsNullOrEmpty() ? string.Empty : prefix + DOT + propertyName);
+ string message = error.Message;
+ bool found = formProperties.Contains(key, StringComparer.InvariantCultureIgnoreCase);
+
+ // find property by alias
+ if (!found && aliases != null && aliases.ContainsKey(propertyName))
+ {
+ key = aliases[propertyName];
+ key = prefix.IsNullOrEmpty() ? key : (key.IsNullOrEmpty() ? string.Empty : prefix + DOT + key);
+ found = formProperties.Contains(key, StringComparer.InvariantCultureIgnoreCase);
+ }
+
+ // move property errors to model if they are not part of the form
+ if (!found && convertUnresolvedPropertiesToModel)
+ {
+ key = string.Empty;
+ }
+
+ modelState.AddModelError(key, message);
+ }
+ }
+
+
+ public static void AddToModelState(this IdentityResult result, ModelStateDictionary modelState)
+ {
+ if (result == null || result.Succeeded)
+ {
+ return;
+ }
+
+ foreach (IdentityError error in result.Errors)
+ {
+ modelState.AddModelError(string.Empty, error.Description);
+ }
+ }
}
\ No newline at end of file