From be3ca6f7cef50cff69ec7ad331fe3259aa82efcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1ra=20El-Saig?= Date: Thu, 21 Nov 2024 20:43:06 +0100 Subject: [PATCH 01/10] Handle FrontendException. --- .../Users/AuthorizationServiceExtensions.cs | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/Lombiq.HelpfulLibraries.OrchardCore/Users/AuthorizationServiceExtensions.cs b/Lombiq.HelpfulLibraries.OrchardCore/Users/AuthorizationServiceExtensions.cs index c430253e..0082d7ba 100644 --- a/Lombiq.HelpfulLibraries.OrchardCore/Users/AuthorizationServiceExtensions.cs +++ b/Lombiq.HelpfulLibraries.OrchardCore/Users/AuthorizationServiceExtensions.cs @@ -1,6 +1,9 @@ -using Microsoft.AspNetCore.Http; +using Lombiq.HelpfulLibraries.AspNetCore.Exceptions; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using OrchardCore.Security.Permissions; using System; using System.Collections.Generic; @@ -66,17 +69,26 @@ public static async Task AuthorizeForCurrentUserValidateAndExecut } } - var (isSuccess, data) = validateAsync is null ? (true, default) : await validateAsync(); - if (!isSuccess) return controller.NotFound(); + try + { + var (isSuccess, data) = validateAsync is null ? (true, default) : await validateAsync(); + if (!isSuccess) return controller.NotFound(); + + var result = await executeAsync(data); - var result = await executeAsync(data); + if (checkModelState && !controller.ModelState.IsValid) + { + return controller.ValidationProblem(controller.ModelState); + } - if (checkModelState && !controller.ModelState.IsValid) + return result as IActionResult ?? controller.Ok(result); + } + catch (FrontendException exception) { - return controller.ValidationProblem(controller.ModelState); + var logger = controller.HttpContext?.RequestServices?.GetService>(); + logger?.LogError(exception, "An error has occurred."); + return controller.BadRequest(string.Join(FrontendException.MessageSeparator, exception.HtmlMessages)); } - - return result is IActionResult actionResult ? actionResult : controller.Ok(result); } /// From 6caa9b60f6771e645d408a1d6a8b907e2e105a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1ra=20El-Saig?= Date: Thu, 21 Nov 2024 21:14:48 +0100 Subject: [PATCH 02/10] Add UserReadableException. --- .../Exceptions/FrontendException.cs | 2 +- .../Docs/Exceptions.md | 3 + .../Exceptions/UserReadableException.cs | 55 +++++++++++++++++++ .../Mvc/OrchardControllerExtensions.cs | 12 ++++ .../Users/AuthorizationServiceExtensions.cs | 5 +- 5 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 Lombiq.HelpfulLibraries.Common/Docs/Exceptions.md create mode 100644 Lombiq.HelpfulLibraries.Common/Exceptions/UserReadableException.cs diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Exceptions/FrontendException.cs b/Lombiq.HelpfulLibraries.AspNetCore/Exceptions/FrontendException.cs index acff2142..7c970d40 100644 --- a/Lombiq.HelpfulLibraries.AspNetCore/Exceptions/FrontendException.cs +++ b/Lombiq.HelpfulLibraries.AspNetCore/Exceptions/FrontendException.cs @@ -29,7 +29,7 @@ public FrontendException(LocalizedHtmlString message, Exception? innerException HtmlMessages = [message]; public FrontendException(ICollection messages, Exception? innerException = null) - : base(string.Join("
", messages.Select(message => message.Value)), innerException) => + : base(string.Join(MessageSeparator, messages.Select(message => message.Value)), innerException) => HtmlMessages = [.. messages]; public FrontendException(string message) diff --git a/Lombiq.HelpfulLibraries.Common/Docs/Exceptions.md b/Lombiq.HelpfulLibraries.Common/Docs/Exceptions.md new file mode 100644 index 00000000..a4812a76 --- /dev/null +++ b/Lombiq.HelpfulLibraries.Common/Docs/Exceptions.md @@ -0,0 +1,3 @@ +# Lombiq Helpful Libraries - Common - Exceptions + +- `UserReadableException`: An exception whose message is safe to display to the end user. diff --git a/Lombiq.HelpfulLibraries.Common/Exceptions/UserReadableException.cs b/Lombiq.HelpfulLibraries.Common/Exceptions/UserReadableException.cs new file mode 100644 index 00000000..a47aba99 --- /dev/null +++ b/Lombiq.HelpfulLibraries.Common/Exceptions/UserReadableException.cs @@ -0,0 +1,55 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Lombiq.HelpfulLibraries.Common.Exceptions; + +/// +/// An exception whose message is safe to display to the end user of a desktop or command line application. +/// +/// +/// In case of web application, use Lombiq.HelpfulLibraries.AspNetCore's FrontendException instead. +/// +public class UserReadableException : Exception +{ + /// + /// Gets the list of error messages that can be displayed to the user. + /// + public IReadOnlyList Messages { get; } = []; + + public UserReadableException(ICollection messages, Exception? innerException = null) + : base(string.Join(Environment.NewLine, messages), innerException) => + Messages = [.. messages]; + + public UserReadableException(string message) + : this([message]) + { + } + + public UserReadableException() + { + } + + public UserReadableException(string message, Exception? innerException) + : this([message], innerException) + { + } + + /// + /// If the provided collection of is not empty, it throws an exception with the included + /// texts. + /// + /// The possible collection of error texts. + /// The non-empty error messages from . + public static void ThrowIfAny(ICollection? errors) + { + errors = errors?.WhereNot(string.IsNullOrWhiteSpace).ToList(); + + if (errors == null || errors.Count == 0) return; + if (errors.Count == 1) throw new UserReadableException(errors.Single()); + + throw new UserReadableException(errors); + } +} diff --git a/Lombiq.HelpfulLibraries.OrchardCore/Mvc/OrchardControllerExtensions.cs b/Lombiq.HelpfulLibraries.OrchardCore/Mvc/OrchardControllerExtensions.cs index 33d70cae..41e5dbf0 100644 --- a/Lombiq.HelpfulLibraries.OrchardCore/Mvc/OrchardControllerExtensions.cs +++ b/Lombiq.HelpfulLibraries.OrchardCore/Mvc/OrchardControllerExtensions.cs @@ -1,4 +1,5 @@ using Lombiq.HelpfulLibraries.AspNetCore.Exceptions; +using Lombiq.HelpfulLibraries.Common.Exceptions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.AspNetCore.Mvc.Routing; @@ -8,6 +9,7 @@ using OrchardCore.ContentManagement; using System; using System.Linq; +using System.Text.Encodings.Web; using System.Text.Json; using System.Threading.Tasks; @@ -50,6 +52,16 @@ public static async Task SafeJsonAsync(this Controller controller { return controller.Json(await dataFactory()); } + catch (UserReadableException exception) + { + LogJsonError(controller, exception); + return controller.Json(new + { + error = exception.Message, + html = exception.Messages.Select(HtmlEncoder.Default.Encode), + data = context.IsDevelopmentAndLocalhost(), + }); + } catch (FrontendException exception) { LogJsonError(controller, exception); diff --git a/Lombiq.HelpfulLibraries.OrchardCore/Users/AuthorizationServiceExtensions.cs b/Lombiq.HelpfulLibraries.OrchardCore/Users/AuthorizationServiceExtensions.cs index 0082d7ba..d9c54d66 100644 --- a/Lombiq.HelpfulLibraries.OrchardCore/Users/AuthorizationServiceExtensions.cs +++ b/Lombiq.HelpfulLibraries.OrchardCore/Users/AuthorizationServiceExtensions.cs @@ -1,4 +1,5 @@ using Lombiq.HelpfulLibraries.AspNetCore.Exceptions; +using Lombiq.HelpfulLibraries.Common.Exceptions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -83,11 +84,11 @@ public static async Task AuthorizeForCurrentUserValidateAndExecut return result as IActionResult ?? controller.Ok(result); } - catch (FrontendException exception) + catch (Exception exception) when (exception is UserReadableException or FrontendException) { var logger = controller.HttpContext?.RequestServices?.GetService>(); logger?.LogError(exception, "An error has occurred."); - return controller.BadRequest(string.Join(FrontendException.MessageSeparator, exception.HtmlMessages)); + return controller.BadRequest(exception.Message); } } From 10f42584993d7102fbf453d8e275079952fd9408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1ra=20El-Saig?= Date: Tue, 3 Dec 2024 11:59:57 +0100 Subject: [PATCH 03/10] Add ZipArchiveExtensions. --- .../Extensions/ZipArchiveExtensions.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs diff --git a/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs b/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs new file mode 100644 index 00000000..5f576c54 --- /dev/null +++ b/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; + +namespace Lombiq.HelpfulLibraries.Common.Extensions; + +public static class ZipArchiveExtensions +{ + /// + /// Creates a new text file in and writes the into it. + /// + public static async Task CreateTextEntryAsync(ZipArchive zip, string entryName, IEnumerable lines) + { + await using var writer = new StreamWriter(zip.CreateEntry(entryName).Open()); + + foreach (var line in lines) + { + await writer.WriteLineAsync(line); + } + } + + /// + /// Creates a new binary file in and writes the into it. + /// + public static async Task CreateBinaryEntryAsync(ZipArchive zip, string entryName, byte[] data) + { + await using var stream = zip.CreateEntry(entryName).Open(); + await stream.WriteAsync(data); + } +} From 96197a64ce97c61ca1eebdce7852089f90022e21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1ra=20El-Saig?= Date: Tue, 3 Dec 2024 12:00:46 +0100 Subject: [PATCH 04/10] namespace --- .../Extensions/ZipArchiveExtensions.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs b/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs index 5f576c54..891a33d4 100644 --- a/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs +++ b/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs @@ -1,9 +1,7 @@ using System.Collections.Generic; -using System.IO; -using System.IO.Compression; using System.Threading.Tasks; -namespace Lombiq.HelpfulLibraries.Common.Extensions; +namespace System.IO.Compression; public static class ZipArchiveExtensions { From 4328568e43220a47a0a7cd49ad0b7bfc083cfb54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1ra=20El-Saig?= Date: Tue, 3 Dec 2024 12:04:27 +0100 Subject: [PATCH 05/10] Add single line overload. --- .../Extensions/ZipArchiveExtensions.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs b/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs index 891a33d4..d7872c1f 100644 --- a/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs +++ b/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs @@ -8,7 +8,7 @@ public static class ZipArchiveExtensions /// /// Creates a new text file in and writes the into it. /// - public static async Task CreateTextEntryAsync(ZipArchive zip, string entryName, IEnumerable lines) + public static async Task CreateTextEntryAsync(this ZipArchive zip, string entryName, IEnumerable lines) { await using var writer = new StreamWriter(zip.CreateEntry(entryName).Open()); @@ -18,10 +18,16 @@ public static async Task CreateTextEntryAsync(ZipArchive zip, string entryName, } } + /// + /// Creates a new text file in and writes the into it. + /// + public static Task CreateTextEntryAsync(this ZipArchive zip, string entryName, string text) => + zip.CreateTextEntryAsync(entryName, [text]); + /// /// Creates a new binary file in and writes the into it. /// - public static async Task CreateBinaryEntryAsync(ZipArchive zip, string entryName, byte[] data) + public static async Task CreateBinaryEntryAsync(this ZipArchive zip, string entryName, byte[] data) { await using var stream = zip.CreateEntry(entryName).Open(); await stream.WriteAsync(data); From 440e2f301b0d661d3fba0bbfe74a8bc0413b812b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1ra=20El-Saig?= Date: Tue, 3 Dec 2024 15:23:17 +0100 Subject: [PATCH 06/10] Use less strict data type. --- .../Extensions/ZipArchiveExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs b/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs index d7872c1f..539edf24 100644 --- a/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs +++ b/Lombiq.HelpfulLibraries.Common/Extensions/ZipArchiveExtensions.cs @@ -27,7 +27,7 @@ public static Task CreateTextEntryAsync(this ZipArchive zip, string entryName, s /// /// Creates a new binary file in and writes the into it. /// - public static async Task CreateBinaryEntryAsync(this ZipArchive zip, string entryName, byte[] data) + public static async Task CreateBinaryEntryAsync(this ZipArchive zip, string entryName, ReadOnlyMemory data) { await using var stream = zip.CreateEntry(entryName).Open(); await stream.WriteAsync(data); From 7855d85fdb7d8b6cb2c2c5129b427c2d3677ca88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1ra=20El-Saig?= Date: Tue, 3 Dec 2024 20:26:39 +0100 Subject: [PATCH 07/10] Add HttpContentExtensions. --- .../Extensions/HttpContentExtensions.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs b/Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs new file mode 100644 index 00000000..01fa4001 --- /dev/null +++ b/Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text; + +namespace System.Net.Http; + +public static class HttpContentExtensions +{ + /// + /// Attaches a new file field to this web request content. + /// + /// The form content of the request. + /// The name of the field. + /// The name and extension of the file being uploaded. + /// The file's MIME type (use ). + /// The content of the file. + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The parent form should be disposed instead.")] + public static void AddFile( + this MultipartFormDataContent form, + string name, + string fileName, + string mediaType, + byte[] content) + { + var xml = new ByteArrayContent(content); + xml.Headers.ContentType = MediaTypeHeaderValue.Parse(mediaType); + form.Add(xml, name, fileName); + } + + /// + /// The content of the file. It will be encoded as UTF-8. + public static void AddFile( + this MultipartFormDataContent form, + string name, + string fileName, + string mediaType, + string content) => + form.AddFile(name, fileName, mediaType, Encoding.UTF8.GetBytes(content)); + +} From 336c49e38619acacfb5b223622d2f0853bd25438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1ra=20El-Saig?= Date: Tue, 3 Dec 2024 21:00:06 +0100 Subject: [PATCH 08/10] Fix spacing --- .../Extensions/HttpContentExtensions.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs b/Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs index 01fa4001..f62069bb 100644 --- a/Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs +++ b/Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs @@ -40,5 +40,4 @@ public static void AddFile( string mediaType, string content) => form.AddFile(name, fileName, mediaType, Encoding.UTF8.GetBytes(content)); - } From cacfb934ccd0b998476029bd71431d5cdbb74120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1ra=20El-Saig?= Date: Tue, 3 Dec 2024 21:36:06 +0100 Subject: [PATCH 09/10] Add AddLocalFile extension method. --- .../Extensions/HttpContentExtensions.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs b/Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs index f62069bb..2357d0c6 100644 --- a/Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs +++ b/Lombiq.HelpfulLibraries.AspNetCore/Extensions/HttpContentExtensions.cs @@ -1,4 +1,6 @@ +using Microsoft.AspNetCore.StaticFiles; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Net.Http.Headers; using System.Net.Mime; using System.Text; @@ -40,4 +42,24 @@ public static void AddFile( string mediaType, string content) => form.AddFile(name, fileName, mediaType, Encoding.UTF8.GetBytes(content)); + + /// + /// Adds a file from disk. The file name is derived from and if + /// is , then it's guessed from the file name as well. + /// + public static void AddLocalFile( + this MultipartFormDataContent form, + string name, + string path, + string mediaType = null) + { + if (string.IsNullOrEmpty(mediaType) && + !new FileExtensionContentTypeProvider().TryGetContentType(path, out mediaType)) + { + // Fall back to a media type that indicates unspecified binary data. + mediaType = MediaTypeNames.Application.Octet; + } + + form.AddFile(name, Path.GetFileName(path), mediaType, File.ReadAllBytes(path)); + } } From 51a592f4a0301fcc42a30f6ca487e355aa6d7960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A1ra=20El-Saig?= Date: Wed, 4 Dec 2024 11:55:06 +0100 Subject: [PATCH 10/10] Documentation fixes. --- Lombiq.HelpfulLibraries.AspNetCore/Docs/Extensions.md | 1 + Lombiq.HelpfulLibraries.Common/Readme.md | 1 + 2 files changed, 2 insertions(+) diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Docs/Extensions.md b/Lombiq.HelpfulLibraries.AspNetCore/Docs/Extensions.md index 0dccc3ab..b6e9e424 100644 --- a/Lombiq.HelpfulLibraries.AspNetCore/Docs/Extensions.md +++ b/Lombiq.HelpfulLibraries.AspNetCore/Docs/Extensions.md @@ -5,6 +5,7 @@ - `DateTimeHttpContextExtensions`: Makes it possible to set or get IANA time-zone IDs in the HTTP context. - `EnvironmentHttpContextExtensions`: Provides shortcuts to determine information about the current hosting environment, like whether the app is running in Development mode. - `ForwardedHeadersApplicationBuilderExtensions`: Provides `UseForwardedHeadersForCloudflareAndAzure()` that forwards proxied headers onto the current request with settings suitable for an app behind Cloudflare and hosted in an Azure App Service. +- `HttpContentExtensions`: Extensions for `HttpContent` types, which are used as message body when sending requests via `HttpClient`. - `JsonStringExtensions`: Adds JSON related extensions for the `string` type. For example, `JsonHtmlContent` safely serializes a string for use in `