diff --git a/Directory.Packages.props b/Directory.Packages.props index 9a5cc913082..9609656c7bc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,6 +36,7 @@ + diff --git a/OrchardCore.sln b/OrchardCore.sln index ed5c85aeefb..e1b183dee7e 100644 --- a/OrchardCore.sln +++ b/OrchardCore.sln @@ -585,6 +585,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Taxonomies.Core EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Flows.Core", "src\OrchardCore\OrchardCore.Flows.Core\OrchardCore.Flows.Core.csproj", "{F7F3AFBD-8045-49D3-BA06-504954D6910B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Security.Core", "src\OrchardCore\OrchardCore.Security.Core\OrchardCore.Security.Core.csproj", "{697B126E-3AB6-46A7-A491-AF3E195D6836}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Azure", "src\OrchardCore.Modules\OrchardCore.Azure\OrchardCore.Azure.csproj", "{7B0C21D9-9365-8CBC-1A2B-52322BAC9153}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Azure.Core", "src\OrchardCore\OrchardCore.Azure.Core\OrchardCore.Azure.Core.csproj", "{08B1CAEE-D15E-CB2C-29C9-3771714D2E15}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1481,6 +1487,18 @@ Global {F7F3AFBD-8045-49D3-BA06-504954D6910B}.Debug|Any CPU.Build.0 = Debug|Any CPU {F7F3AFBD-8045-49D3-BA06-504954D6910B}.Release|Any CPU.ActiveCfg = Release|Any CPU {F7F3AFBD-8045-49D3-BA06-504954D6910B}.Release|Any CPU.Build.0 = Release|Any CPU + {697B126E-3AB6-46A7-A491-AF3E195D6836}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {697B126E-3AB6-46A7-A491-AF3E195D6836}.Debug|Any CPU.Build.0 = Debug|Any CPU + {697B126E-3AB6-46A7-A491-AF3E195D6836}.Release|Any CPU.ActiveCfg = Release|Any CPU + {697B126E-3AB6-46A7-A491-AF3E195D6836}.Release|Any CPU.Build.0 = Release|Any CPU + {7B0C21D9-9365-8CBC-1A2B-52322BAC9153}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B0C21D9-9365-8CBC-1A2B-52322BAC9153}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B0C21D9-9365-8CBC-1A2B-52322BAC9153}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B0C21D9-9365-8CBC-1A2B-52322BAC9153}.Release|Any CPU.Build.0 = Release|Any CPU + {08B1CAEE-D15E-CB2C-29C9-3771714D2E15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08B1CAEE-D15E-CB2C-29C9-3771714D2E15}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08B1CAEE-D15E-CB2C-29C9-3771714D2E15}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08B1CAEE-D15E-CB2C-29C9-3771714D2E15}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1734,6 +1752,9 @@ Global {51C07EE9-9420-4BFE-911B-9F4326A0F83B} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} {6A379AE9-B468-4D89-82B2-0C350AB712F9} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} {F7F3AFBD-8045-49D3-BA06-504954D6910B} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} + {697B126E-3AB6-46A7-A491-AF3E195D6836} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} + {7B0C21D9-9365-8CBC-1A2B-52322BAC9153} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} + {08B1CAEE-D15E-CB2C-29C9-3771714D2E15} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {46A1D25A-78D1-4476-9CBF-25B75E296341} diff --git a/OrchardCore.sln.rej b/OrchardCore.sln.rej new file mode 100644 index 00000000000..8b6da29312b --- /dev/null +++ b/OrchardCore.sln.rej @@ -0,0 +1,43 @@ +diff a/OrchardCore.sln b/OrchardCore.sln (rejected hunks) +@@ -585,6 +585,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Taxonomies.Core + EndProject + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Flows.Core", "src\OrchardCore\OrchardCore.Flows.Core\OrchardCore.Flows.Core.csproj", "{F7F3AFBD-8045-49D3-BA06-504954D6910B}" + EndProject ++Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Redis.Azure", "src\OrchardCore.Modules\OrchardCore.Redis.Azure\OrchardCore.Redis.Azure.csproj", "{61848F3E-972A-4AD3-965E-DC42E690BF05}" ++EndProject ++Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Azure.Core", "src\OrchardCore\OrchardCore.Azure.Core\OrchardCore.Azure.Core.csproj", "{903FFEBA-B69D-4464-8391-9240C9EB8384}" ++EndProject ++Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Azure", "src\OrchardCore.Modules\OrchardCore.Azure\OrchardCore.Azure.csproj", "{5BA3337B-2266-40A8-87A2-C4497D260FE0}" ++EndProject + Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU +@@ -1481,6 +1487,18 @@ Global + {F7F3AFBD-8045-49D3-BA06-504954D6910B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7F3AFBD-8045-49D3-BA06-504954D6910B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7F3AFBD-8045-49D3-BA06-504954D6910B}.Release|Any CPU.Build.0 = Release|Any CPU ++ {61848F3E-972A-4AD3-965E-DC42E690BF05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU ++ {61848F3E-972A-4AD3-965E-DC42E690BF05}.Debug|Any CPU.Build.0 = Debug|Any CPU ++ {61848F3E-972A-4AD3-965E-DC42E690BF05}.Release|Any CPU.ActiveCfg = Release|Any CPU ++ {61848F3E-972A-4AD3-965E-DC42E690BF05}.Release|Any CPU.Build.0 = Release|Any CPU ++ {903FFEBA-B69D-4464-8391-9240C9EB8384}.Debug|Any CPU.ActiveCfg = Debug|Any CPU ++ {903FFEBA-B69D-4464-8391-9240C9EB8384}.Debug|Any CPU.Build.0 = Debug|Any CPU ++ {903FFEBA-B69D-4464-8391-9240C9EB8384}.Release|Any CPU.ActiveCfg = Release|Any CPU ++ {903FFEBA-B69D-4464-8391-9240C9EB8384}.Release|Any CPU.Build.0 = Release|Any CPU ++ {5BA3337B-2266-40A8-87A2-C4497D260FE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU ++ {5BA3337B-2266-40A8-87A2-C4497D260FE0}.Debug|Any CPU.Build.0 = Debug|Any CPU ++ {5BA3337B-2266-40A8-87A2-C4497D260FE0}.Release|Any CPU.ActiveCfg = Release|Any CPU ++ {5BA3337B-2266-40A8-87A2-C4497D260FE0}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE +@@ -1734,6 +1752,9 @@ Global + {51C07EE9-9420-4BFE-911B-9F4326A0F83B} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} + {6A379AE9-B468-4D89-82B2-0C350AB712F9} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} + {F7F3AFBD-8045-49D3-BA06-504954D6910B} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} ++ {61848F3E-972A-4AD3-965E-DC42E690BF05} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} ++ {903FFEBA-B69D-4464-8391-9240C9EB8384} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} ++ {5BA3337B-2266-40A8-87A2-C4497D260FE0} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {46A1D25A-78D1-4476-9CBF-25B75E296341} diff --git a/mkdocs.yml b/mkdocs.yml index 0151c2f86d4..868d1e1d26a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -243,6 +243,7 @@ nav: - Display Management: reference/modules/DisplayManagement/README.md - Audit Trail: reference/modules/AuditTrail/README.md - Auto Setup: reference/modules/AutoSetup/README.md + - Azure: reference/modules/Azure/README.md - Features: reference/modules/Features/README.md - Contents: reference/modules/Contents/README.md - Configuration: reference/modules/Configuration/README.md @@ -271,6 +272,7 @@ nav: - Razor Helpers: reference/modules/Razor/README.md - Recipes: reference/modules/Recipes/README.md - Redis: reference/modules/Redis/README.md + - Redis Azure: reference/modules/Redis.Azure/README.md - Remote Deployment: reference/modules/Deployment.Remote/README.md - Response Compression: reference/modules/ResponseCompression/README.md - Roles: reference/modules/Roles/README.md diff --git a/src/OrchardCore.Modules/OrchardCore.Azure/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Azure/Manifest.cs new file mode 100644 index 00000000000..f5f56731a1b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Azure/Manifest.cs @@ -0,0 +1,10 @@ +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "OrchardCore.Azure", + Author = ManifestConstants.OrchardCoreTeam, + Website = ManifestConstants.OrchardCoreWebsite, + Version = ManifestConstants.OrchardCoreVersion, + Description = "Provides a way to manage Azure credentials", + Category = "Azure" +)] diff --git a/src/OrchardCore.Modules/OrchardCore.Azure/OrchardCore.Azure.csproj b/src/OrchardCore.Modules/OrchardCore.Azure/OrchardCore.Azure.csproj new file mode 100644 index 00000000000..a265e863c67 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Azure/OrchardCore.Azure.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Azure/Startup.cs new file mode 100644 index 00000000000..2e9c80b0e28 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Azure/Startup.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Azure.Core; +using OrchardCore.Modules; + +namespace OrchardCore.Azure; + +public class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddAzureOptions(); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Drivers/AzureEmailSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Drivers/AzureEmailSettingsDisplayDriver.cs index ded7f2b47ca..c8d227df59d 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Drivers/AzureEmailSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Drivers/AzureEmailSettingsDisplayDriver.cs @@ -58,6 +58,7 @@ public override async Task EditAsync(ISite site, AzureEmailSetti { model.IsEnabled = settings.IsEnabled; model.DefaultSender = settings.DefaultSender; + model.HasConnectionString = !string.IsNullOrWhiteSpace(settings.ConnectionString); }).Location("Content:5#Azure Communication Services") .OnGroup(SettingsGroupId); diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Models/AzureEmailOptions.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Models/AzureEmailOptions.cs index e83afa49f0e..e414b8d0c02 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Models/AzureEmailOptions.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Models/AzureEmailOptions.cs @@ -8,6 +8,22 @@ public class AzureEmailOptions public string ConnectionString { get; set; } + public string CredentialName { get; set; } + + public Uri Endpoint { get; set; } + public bool ConfigurationExists() - => !string.IsNullOrWhiteSpace(DefaultSender) && !string.IsNullOrWhiteSpace(ConnectionString); + { + if (string.IsNullOrWhiteSpace(DefaultSender)) + { + return false; + } + + if (Endpoint is not null) + { + return true; + } + + return !string.IsNullOrWhiteSpace(ConnectionString); + } } diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailOptionsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailOptionsConfiguration.cs index a366e3ff71e..4d75ad9e220 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailOptionsConfiguration.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailOptionsConfiguration.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Email.Azure; using OrchardCore.Email.Azure.Models; using OrchardCore.Settings; @@ -32,7 +33,20 @@ public void Configure(AzureEmailOptions options) { var protector = _dataProtectionProvider.CreateProtector(ProtectorName); - options.ConnectionString = protector.Unprotect(settings.ConnectionString); + var rawConnectionString = protector.Unprotect(settings.ConnectionString); + + options.ConnectionString = rawConnectionString; + + if (options.Endpoint is null) + { + var endpointString = ConnectionStringHelper.Extract(rawConnectionString, "Endpoint"); + + if (endpointString is not null && Uri.TryCreate(endpointString, UriKind.Absolute, out var endpointUri)) + { + options.Endpoint = endpointUri; + } + } } + } } diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailProvider.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailProvider.cs index e5891bddafa..5fe7c6dafbb 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailProvider.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailProvider.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Email.Azure.Models; namespace OrchardCore.Email.Azure.Services; @@ -11,9 +12,10 @@ public class AzureEmailProvider : AzureEmailProviderBase public AzureEmailProvider( IOptions options, + IOptionsMonitor optionsMonitor, ILogger logger, IStringLocalizer stringLocalizer) - : base(options.Value, logger, stringLocalizer) + : base(options.Value, optionsMonitor, logger, stringLocalizer) { } diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailProviderBase.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailProviderBase.cs index 083f71af8f5..2c64e111fb9 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailProviderBase.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailProviderBase.cs @@ -3,6 +3,8 @@ using Azure.Communication.Email; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Email.Azure.Models; namespace OrchardCore.Email.Azure.Services; @@ -79,6 +81,7 @@ public abstract class AzureEmailProviderBase : IEmailProvider }; private readonly AzureEmailOptions _providerOptions; + private readonly IOptionsMonitor _optionsMonitor; private readonly ILogger _logger; private EmailClient _emailClient; @@ -87,10 +90,12 @@ public abstract class AzureEmailProviderBase : IEmailProvider public AzureEmailProviderBase( AzureEmailOptions options, + IOptionsMonitor optionsMonitor, ILogger logger, IStringLocalizer stringLocalizer) { _providerOptions = options; + _optionsMonitor = optionsMonitor; _logger = logger; S = stringLocalizer; } @@ -135,7 +140,28 @@ public virtual async Task SendAsync(MailMessage message) try { - _emailClient ??= new EmailClient(_providerOptions.ConnectionString); + if (_emailClient is null) + { + if (!string.IsNullOrEmpty(_providerOptions.ConnectionString)) + { + _emailClient = new EmailClient(_providerOptions.ConnectionString); + } + else if (_providerOptions.Endpoint is not null) + { + var azureOptions = _optionsMonitor.Get(_providerOptions.CredentialName ?? AzureOptions.DefaultName); + + if (azureOptions is null) + { + return EmailResult.FailedResult(string.Empty, S["Unsupported Authentication Type. The Azure Email Provider is not configured correctly."]); + } + + _emailClient = new EmailClient(_providerOptions.Endpoint, azureOptions.ToTokenCredential()); + } + else + { + return EmailResult.FailedResult(string.Empty, S["The Azure Email Provider is not configured correctly."]); + } + } var emailResult = await _emailClient.SendAsync(WaitUntil.Completed, emailMessage); diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/DefaultAzureEmailProvider.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/DefaultAzureEmailProvider.cs index 009189cc46c..b3a4eedb7a8 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/DefaultAzureEmailProvider.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/DefaultAzureEmailProvider.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Email.Azure.Models; namespace OrchardCore.Email.Azure.Services; @@ -11,9 +12,10 @@ public class DefaultAzureEmailProvider : AzureEmailProviderBase public DefaultAzureEmailProvider( IOptions options, + IOptionsMonitor optionsMonitor, ILogger logger, IStringLocalizer stringLocalizer) - : base(options.Value, logger, stringLocalizer) + : base(options.Value, optionsMonitor, logger, stringLocalizer) { } diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Startup.cs index 4ecf3dca2fb..daae99b8e5b 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Startup.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Azure.Email.Drivers; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.Email.Azure.Models; @@ -34,6 +35,16 @@ public void ConfigureServices(IServiceCollection services) // The 'OrchardCore_Email_Azure' key can be removed in version 3. _shellConfiguration.GetSection("OrchardCore_Email_Azure").Bind(options); + if (options.Endpoint is null && !string.IsNullOrEmpty(options.ConnectionString)) + { + var endpointString = ConnectionStringHelper.Extract(options.ConnectionString, "Endpoint"); + + if (endpointString is not null && Uri.TryCreate(endpointString, UriKind.Absolute, out var endpointUri)) + { + options.Endpoint = endpointUri; + } + } + options.IsEnabled = options.ConfigurationExists(); }); } diff --git a/src/OrchardCore.Modules/OrchardCore.Indexing/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Indexing/Controllers/AdminController.cs index b2e7b0b9057..aebf7121400 100644 --- a/src/OrchardCore.Modules/OrchardCore.Indexing/Controllers/AdminController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Indexing/Controllers/AdminController.cs @@ -93,12 +93,12 @@ public async Task Index( .ThenBy(x => x.Type), }; - foreach (var record in result.Models) + foreach (var entry in result.Entries) { viewModel.Models.Add(new ModelEntry { - Model = record, - Shape = await _displayManager.BuildDisplayAsync(record, _updateModelAccessor.ModelUpdater, "SummaryAdmin"), + Model = entry, + Shape = await _displayManager.BuildDisplayAsync(entry, _updateModelAccessor.ModelUpdater, "SummaryAdmin"), }); } diff --git a/src/OrchardCore.Modules/OrchardCore.Indexing/DataMigrations/PreviewIndexingMigrations.cs b/src/OrchardCore.Modules/OrchardCore.Indexing/DataMigrations/PreviewIndexingMigrations.cs index f3608cdff38..420913401f6 100644 --- a/src/OrchardCore.Modules/OrchardCore.Indexing/DataMigrations/PreviewIndexingMigrations.cs +++ b/src/OrchardCore.Modules/OrchardCore.Indexing/DataMigrations/PreviewIndexingMigrations.cs @@ -1,9 +1,9 @@ using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Catalogs; using OrchardCore.Data.Migration; using OrchardCore.Documents; using OrchardCore.Environment.Shell; using OrchardCore.Environment.Shell.Scope; -using OrchardCore.Indexing.Core; using OrchardCore.Indexing.Models; using YesSql; diff --git a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs index 903d45f22f3..5eb3513a266 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Environment.Shell; using OrchardCore.Environment.Shell.Configuration; using OrchardCore.FileStorage; @@ -92,12 +93,13 @@ public override void ConfigureServices(IServiceCollection services) var shellSettings = serviceProvider.GetRequiredService(); var mediaOptions = serviceProvider.GetRequiredService>().Value; var clock = serviceProvider.GetRequiredService(); + var optionsMonitor = serviceProvider.GetRequiredService>(); var contentTypeProvider = serviceProvider.GetRequiredService(); var mediaEventHandlers = serviceProvider.GetServices(); var mediaCreatingEventHandlers = serviceProvider.GetServices(); var logger = serviceProvider.GetRequiredService>(); - var fileStore = new BlobFileStore(blobStorageOptions, clock, contentTypeProvider); + var fileStore = new BlobFileStore(blobStorageOptions, clock, optionsMonitor, contentTypeProvider); var mediaUrlBase = "/" + fileStore.Combine(shellSettings.RequestUrlPrefix, mediaOptions.AssetsRequestPath); var originalPathBase = serviceProvider.GetRequiredService().HttpContext diff --git a/src/OrchardCore.Modules/OrchardCore.Redis.Azure/AzureRedisTokenProvider.cs b/src/OrchardCore.Modules/OrchardCore.Redis.Azure/AzureRedisTokenProvider.cs new file mode 100644 index 00000000000..8e6b5e9d7f3 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Redis.Azure/AzureRedisTokenProvider.cs @@ -0,0 +1,47 @@ +using Azure.Core; +using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; + +namespace OrchardCore.Redis.Azure; + +public sealed class AzureRedisTokenProvider : ITokenProvider +{ + private readonly IOptionsMonitor _options; + private readonly RedisOptions _redisOptions; + + public AzureRedisTokenProvider( + IOptionsMonitor options, + IOptions redisOptions) + { + _options = options; + _redisOptions = redisOptions.Value; + } + + public async Task GetTokenAsync() + { + var redisOptions = _options.Get(_redisOptions.CredentialName ?? AzureOptions.DefaultName); + + var scope = redisOptions.GetProperty("Scope"); + + if (string.IsNullOrEmpty(scope)) + { + scope = "https://redis.azure.com/.default"; + } + + var credential = redisOptions.ToTokenCredential(); + + if (credential is null) + { + throw new InvalidOperationException($"Unable to create a valid TokenCredential for RedisOptions '{_redisOptions.CredentialName}'."); + } + + var requestContext = new TokenRequestContext([scope]); + + var result = await credential.GetTokenAsync(requestContext, CancellationToken.None); + + return new TokenResult + { + Token = result.Token, + }; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Redis.Azure/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Redis.Azure/Manifest.cs new file mode 100644 index 00000000000..a8e9ad79833 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Redis.Azure/Manifest.cs @@ -0,0 +1,15 @@ +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "Azure Redis Cache", + Author = ManifestConstants.OrchardCoreTeam, + Website = ManifestConstants.OrchardCoreWebsite, + Version = ManifestConstants.OrchardCoreVersion, + Description = "Distributed cache using Azure Redis.", + Dependencies = + [ + "OrchardCore.Redis", + "OrchardCore.Azure", + ], + Category = "Distributed" +)] diff --git a/src/OrchardCore.Modules/OrchardCore.Redis.Azure/OrchardCore.Redis.Azure.csproj b/src/OrchardCore.Modules/OrchardCore.Redis.Azure/OrchardCore.Redis.Azure.csproj new file mode 100644 index 00000000000..50c2b33cd3f --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Redis.Azure/OrchardCore.Redis.Azure.csproj @@ -0,0 +1,30 @@ + + + + + OrchardCore Redis Azure + + $(OCFrameworkDescription) + + Provides Azure Redis features for configuration, cache, bus and lock. + + $(PackageTags) OrchardCoreFramework Infrastructure + + + + + + + + + + + + + + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Redis.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Redis.Azure/Startup.cs new file mode 100644 index 00000000000..daec9304b9b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Redis.Azure/Startup.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Modules; + +namespace OrchardCore.Redis.Azure; + +public sealed class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddKeyedTransient("Redis"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Redis/OrchardCore.Redis.csproj b/src/OrchardCore.Modules/OrchardCore.Redis/OrchardCore.Redis.csproj index d4d11456551..c7624197105 100644 --- a/src/OrchardCore.Modules/OrchardCore.Redis/OrchardCore.Redis.csproj +++ b/src/OrchardCore.Modules/OrchardCore.Redis/OrchardCore.Redis.csproj @@ -3,9 +3,11 @@ OrchardCore Redis - $(OCFrameworkDescription) + + $(OCFrameworkDescription) - Provides Redis features for configuration, cache, bus and lock. + Provides Redis features for configuration, cache, bus and lock. + $(PackageTags) OrchardCoreFramework Infrastructure diff --git a/src/OrchardCore.Modules/OrchardCore.Redis/Services/RedisDatabaseFactory.cs b/src/OrchardCore.Modules/OrchardCore.Redis/Services/RedisDatabaseFactory.cs index 352cb0ec12b..03d933bf6d5 100644 --- a/src/OrchardCore.Modules/OrchardCore.Redis/Services/RedisDatabaseFactory.cs +++ b/src/OrchardCore.Modules/OrchardCore.Redis/Services/RedisDatabaseFactory.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using StackExchange.Redis; @@ -14,14 +15,19 @@ public sealed class RedisDatabaseFactory : IRedisDatabaseFactory, IDisposable private static volatile int _registered; private static volatile int _refCount; + private readonly IServiceProvider _serviceProvider; private readonly IHostApplicationLifetime _lifetime; private readonly ILogger _logger; - public RedisDatabaseFactory(IHostApplicationLifetime lifetime, ILogger logger) + public RedisDatabaseFactory( + IServiceProvider serviceProvider, + IHostApplicationLifetime lifetime, + ILogger logger) { Interlocked.Increment(ref _refCount); - + _serviceProvider = serviceProvider; _lifetime = lifetime; + if (Interlocked.CompareExchange(ref _registered, 1, 0) == 0) { _lifetime.ApplicationStopped.Register(Release); @@ -30,25 +36,59 @@ public RedisDatabaseFactory(IHostApplicationLifetime lifetime, ILogger CreateAsync(RedisOptions options) => - _factories.GetOrAdd(options.Configuration, new Lazy>(async () => + public Task CreateAsync(RedisOptions options) + { + return _factories.GetOrAdd(options.ConnectionIdentifier, new Lazy>(async () => { - try + var provider = _serviceProvider.GetKeyedService("Redis"); + + var config = options.ConfigurationOptions; + + if (provider is null) { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug("Creating a new instance of '{Name}'. A single instance per configuration should be created across tenants. Total instances prior creating is '{Count}'.", nameof(ConnectionMultiplexer), _factories.Count); - } + var connection = await ConnectionMultiplexer.ConnectAsync(config); + + return connection.GetDatabase(); + } - return (await ConnectionMultiplexer.ConnectAsync(options.ConfigurationOptions)).GetDatabase(); + var result = await provider.GetTokenAsync(); + + if (!string.IsNullOrEmpty(result?.Token)) + { + config.Password = result.Token; } - catch (Exception e) + + var attempt = 0; + + while (attempt < 3) { - _logger.LogError(e, "Unable to connect to Redis."); + try + { + var connection = await ConnectionMultiplexer.ConnectAsync(config); + return connection.GetDatabase(); + } + catch (RedisConnectionException ex) when (ex.Message.Contains("WRONGPASS") || ex.Message.Contains("NOAUTH")) + { + attempt++; + _logger.LogWarning(ex, "Redis authentication failed, retry attempt {Attempt}", attempt); + + if (provider == null) + { + break; + } - return null; + result = await provider.GetTokenAsync(); + + if (!string.IsNullOrEmpty(result?.Token)) + { + config.Password = result.Token; + } + } } + + throw new InvalidOperationException("Unable to authenticate to Redis after multiple attempts."); })).Value; + } public void Dispose() { @@ -63,7 +103,6 @@ internal static void Release() if (Interlocked.CompareExchange(ref _refCount, 0, 0) == 0) { var factories = _factories.Values.ToArray(); - _factories.Clear(); foreach (var factory in factories) diff --git a/src/OrchardCore.Modules/OrchardCore.Redis/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Redis/Startup.cs index 8187558c3eb..29c4a1c21b7 100644 --- a/src/OrchardCore.Modules/OrchardCore.Redis/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Redis/Startup.cs @@ -1,5 +1,6 @@ using System.Net.Security; using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection.KeyManagement; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.StackExchangeRedis; @@ -11,6 +12,7 @@ using OrchardCore.Environment.Cache; using OrchardCore.Environment.Shell; using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Environment.Shell.Scope; using OrchardCore.Locking.Distributed; using OrchardCore.Modules; using OrchardCore.Redis.Options; @@ -55,6 +57,10 @@ public override void ConfigureServices(IServiceCollection services) services.Configure(options => { + var protectorProvider = ShellScope.Services.GetRequiredService(); + var protector = protectorProvider.CreateProtector("RedisOptions"); + + options.ConnectionIdentifier = protector.Protect(configuration); options.Configuration = configuration; options.ConfigurationOptions = configurationOptions; options.InstancePrefix = instancePrefix; diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchDefaultSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchDefaultSettingsDisplayDriver.cs index c47bb616c0c..0f8a254a073 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchDefaultSettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Drivers/AzureAISearchDefaultSettingsDisplayDriver.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.DisplayManagement.Entities; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; @@ -58,9 +59,16 @@ public override IDisplayResult Edit(ISite site, AzureAISearchDefaultSettings set { model.AuthenticationTypes = [ - new SelectListItem(S["Default"], nameof(AzureAIAuthenticationType.Default)), - new SelectListItem(S["Managed Identity"], nameof(AzureAIAuthenticationType.ManagedIdentity)), - new SelectListItem(S["API Key"], nameof(AzureAIAuthenticationType.ApiKey)), + new SelectListItem(S["Default"], nameof(AzureAuthenticationType.Default)), + new SelectListItem(S["Azure CLI"], nameof(AzureAuthenticationType.AzureCli)), + new SelectListItem(S["Visual Studio"], nameof(AzureAuthenticationType.VisualStudio)), + new SelectListItem(S["Visual Studio Code"], nameof(AzureAuthenticationType.VisualStudioCode)), + new SelectListItem(S["Azure Power Shell"], nameof(AzureAuthenticationType.AzurePowerShell)), + new SelectListItem(S["Environment"], nameof(AzureAuthenticationType.Environment)), + new SelectListItem(S["Interactive Browser"], nameof(AzureAuthenticationType.InteractiveBrowser)), + new SelectListItem(S["Workload Identity"], nameof(AzureAuthenticationType.WorkloadIdentity)), + new SelectListItem(S["Managed Identity"], nameof(AzureAuthenticationType.ManagedIdentity)), + new SelectListItem(S["API Key"], nameof(AzureAuthenticationType.ApiKey)), ]; model.ConfigurationsAreOptional = _searchOptions.FileConfigurationExists(); @@ -112,7 +120,7 @@ public override async Task UpdateAsync(ISite site, AzureAISearch context.Updater.ModelState.AddModelError(Prefix, nameof(model.Endpoint), S["Endpoint must be a valid url."]); } - if (model.AuthenticationType == AzureAIAuthenticationType.ApiKey) + if (model.AuthenticationType == AzureAuthenticationType.ApiKey) { var hasNewKey = !string.IsNullOrWhiteSpace(model.ApiKey); @@ -132,7 +140,7 @@ public override async Task UpdateAsync(ISite site, AzureAISearch settings.UseCustomConfiguration = model.UseCustomConfiguration; if (context.Updater.ModelState.IsValid && - (_searchOptions.Credential?.Key != model.ApiKey || + (_searchOptions.ApiKey != model.ApiKey || _searchOptions.Endpoint != settings.Endpoint || _searchOptions.AuthenticationType != settings.AuthenticationType || _searchOptions.IdentityClientId != settings.IdentityClientId || diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchDefaultSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchDefaultSettingsViewModel.cs index 032f1cc0198..3bb0c1c3547 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchDefaultSettingsViewModel.cs +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AzureAISearchDefaultSettingsViewModel.cs @@ -1,14 +1,14 @@ using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Rendering; -using OrchardCore.Search.AzureAI.Models; +using OrchardCore.Azure.Core; namespace OrchardCore.Search.AzureAI.ViewModels; public class AzureAISearchDefaultSettingsViewModel { [Required] - public AzureAIAuthenticationType? AuthenticationType { get; set; } + public AzureAuthenticationType? AuthenticationType { get; set; } public string Endpoint { get; set; } diff --git a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/AzureAISearchDefaultSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/AzureAISearchDefaultSettings.Edit.cshtml index a24ae4d1ba8..f007260867a 100644 --- a/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/AzureAISearchDefaultSettings.Edit.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Views/AzureAISearchDefaultSettings.Edit.cshtml @@ -1,4 +1,5 @@ @using Microsoft.Extensions.Options +@using OrchardCore.Azure.Core @model AzureAISearchDefaultSettingsViewModel @@ -35,13 +36,13 @@ -
+
- +
-
+
@@ -57,10 +58,10 @@ typeMenu.addEventListener('change', e => { - if (e.target.value == '@nameof(AzureAIAuthenticationType.ApiKey)') { + if (e.target.value == '@nameof(AzureAuthenticationType.ApiKey)') { keyWrapper.classList.remove('d-none'); identityWrapper.classList.add('d-none'); - } else if (e.target.value == '@nameof(AzureAIAuthenticationType.ManagedIdentity)') { + } else if (e.target.value == '@nameof(AzureAuthenticationType.ManagedIdentity)') { keyWrapper.classList.add('d-none'); identityWrapper.classList.remove('d-none'); } else { diff --git a/src/OrchardCore.Modules/OrchardCore.Security/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Security/AdminMenu.cs index fbb5c41c539..9d6d5717655 100644 --- a/src/OrchardCore.Modules/OrchardCore.Security/AdminMenu.cs +++ b/src/OrchardCore.Modules/OrchardCore.Security/AdminMenu.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Localization; using OrchardCore.Navigation; +using OrchardCore.Security.Core; using OrchardCore.Security.Drivers; namespace OrchardCore.Security; @@ -30,7 +31,7 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) .Id("security") .Add(S["Settings"], settings => settings .Add(S["Security Headers"], S["Security Headers"].PrefixPosition(), headers => headers - .Permission(SecurityPermissions.ManageSecurityHeadersSettings) + .Permission(SecurityConstants.Permissions.ManageSecurityHeadersSettings) .Action("Index", "Admin", _routeValues) .LocalNav() ) @@ -44,7 +45,7 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) .Add(S["Settings"], settings => settings .Add(S["Security"], S["Security"].PrefixPosition(), security => security .Add(S["Security Headers"], S["Security Headers"].PrefixPosition(), headers => headers - .Permission(SecurityPermissions.ManageSecurityHeadersSettings) + .Permission(SecurityConstants.Permissions.ManageSecurityHeadersSettings) .Action("Index", "Admin", _routeValues) .LocalNav() ) diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Controllers/CredentialsController.cs b/src/OrchardCore.Modules/OrchardCore.Security/Controllers/CredentialsController.cs new file mode 100644 index 00000000000..2026d20dc98 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Controllers/CredentialsController.cs @@ -0,0 +1,332 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.Catalogs; +using OrchardCore.Catalogs.Models; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Environment.Shell; +using OrchardCore.Modules; +using OrchardCore.Navigation; +using OrchardCore.Routing; +using OrchardCore.Security.Core; + +namespace OrchardCore.Security.Controllers; + +[Feature(SecurityConstants.Features.Credentials)] +public class CredentialsController : Controller +{ + private const string _optionsSearch = "Options.Search"; + + private readonly INamedSourceCatalogManager _manager; + private readonly IAuthorizationService _authorizationService; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly IShellReleaseManager _shellReleaseManager; + private readonly IDisplayManager _displayDriver; + private readonly SecurityOptions _securityOptions; + private readonly INotifier _notifier; + + internal readonly IHtmlLocalizer H; + internal readonly IStringLocalizer S; + + public CredentialsController( + INamedSourceCatalogManager manager, + IAuthorizationService authorizationService, + IUpdateModelAccessor updateModelAccessor, + IShellReleaseManager shellReleaseManager, + IDisplayManager displayManager, + IOptions securityOptions, + INotifier notifier, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer) + { + _manager = manager; + _authorizationService = authorizationService; + _updateModelAccessor = updateModelAccessor; + _shellReleaseManager = shellReleaseManager; + _displayDriver = displayManager; + _securityOptions = securityOptions.Value; + _notifier = notifier; + H = htmlLocalizer; + S = stringLocalizer; + } + + [Admin("security/credentials", "SecurityCredentials")] + public async Task Index( + CatalogEntryOptions options, + PagerParameters pagerParameters, + [FromServices] IOptions pagerOptions, + [FromServices] IShapeFactory shapeFactory) + { + if (!await _authorizationService.AuthorizeAsync(User, SecurityConstants.Permissions.ManageCredentials)) + { + return Forbid(); + } + + var pager = new Pager(pagerParameters, pagerOptions.Value.GetPageSize()); + + var result = await _manager.PageAsync(pager.Page, pager.PageSize, new QueryContext + { + Sorted = true, + Name = options.Search, + }); + + // Maintain previous route data when generating page links. + var routeData = new RouteData(); + + if (!string.IsNullOrEmpty(options.Search)) + { + routeData.Values.TryAdd(_optionsSearch, options.Search); + } + + var viewModel = new ListSourceCatalogEntryViewModel + { + Models = [], + Options = options, + Pager = await shapeFactory.PagerAsync(pager, result.Count, routeData), + Sources = _securityOptions.CredentialProviders.Keys.Order(), + }; + + foreach (var model in result.Entries) + { + viewModel.Models.Add(new CatalogEntryViewModel + { + Model = model, + Shape = await _displayDriver.BuildDisplayAsync(model, _updateModelAccessor.ModelUpdater, "SummaryAdmin"), + }); + } + + viewModel.Options.BulkActions = + [ + new SelectListItem(S["Delete"], nameof(CatalogEntryAction.Remove)), + ]; + + return View(viewModel); + } + + [HttpPost] + [ActionName(nameof(Index))] + [FormValueRequired("submit.Filter")] + [Admin("security/credentials", "SecurityCredentials")] + public ActionResult IndexFilterPost(ListCatalogEntryViewModel model) + { + return RedirectToAction(nameof(Index), new RouteValueDictionary + { + { _optionsSearch, model.Options?.Search }, + }); + } + + [Admin("security/credentials/create/{providerName}", "SecurityCredentialsCreate")] + public async Task Create(string providerName) + { + if (!await _authorizationService.AuthorizeAsync(User, SecurityConstants.Permissions.ManageCredentials)) + { + return Forbid(); + } + + if (!_securityOptions.CredentialProviders.TryGetValue(providerName, out var connectionSource)) + { + await _notifier.ErrorAsync(H["Unable to find a provider with the name '{0}'.", providerName]); + + return RedirectToAction(nameof(Index)); + } + + var model = await _manager.NewAsync(providerName); + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = connectionSource.DisplayName, + Editor = await _displayDriver.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + return View(viewModel); + } + + [HttpPost] + [ActionName(nameof(Create))] + [Admin("security/credentials/create/{providerName}", "SecurityCredentialsCreate")] + public async Task CreatePost(string providerName) + { + if (!await _authorizationService.AuthorizeAsync(User, SecurityConstants.Permissions.ManageCredentials)) + { + return Forbid(); + } + + if (!_securityOptions.CredentialProviders.TryGetValue(providerName, out var connectionSource)) + { + await _notifier.ErrorAsync(H["Unable to find a provider with the name '{0}'.", providerName]); + + return RedirectToAction(nameof(Index)); + } + + var model = await _manager.NewAsync(providerName); + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.DisplayText, + Editor = await _displayDriver.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + if (ModelState.IsValid) + { + _shellReleaseManager.RequestRelease(); + + await _manager.CreateAsync(model); + await _notifier.SuccessAsync(H["A new credential has been created successfully."]); + + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); + } + + [Admin("security/credentials/edit/{id}", "SecurityCredentialsEdit")] + public async Task Edit(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, SecurityConstants.Permissions.ManageCredentials)) + { + return Forbid(); + } + + var model = await _manager.FindByIdAsync(id); + + if (model == null) + { + return NotFound(); + } + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.DisplayText, + Editor = await _displayDriver.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + return View(viewModel); + } + + [HttpPost] + [ActionName(nameof(Edit))] + [Admin("security/credentials/edit/{id}", "SecurityCredentialsEdit")] + public async Task EditPost(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, SecurityConstants.Permissions.ManageCredentials)) + { + return Forbid(); + } + + var model = await _manager.FindByIdAsync(id); + + if (model == null) + { + return NotFound(); + } + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.DisplayText, + Editor = await _displayDriver.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + if (ModelState.IsValid) + { + _shellReleaseManager.RequestRelease(); + + await _manager.UpdateAsync(model); + + await _notifier.SuccessAsync(H["The credential has been updated successfully."]); + + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); + } + + [HttpPost] + [Admin("security/credentials/delete/{id}", "SecurityCredentialsDelete")] + + public async Task Delete(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, SecurityConstants.Permissions.ManageCredentials)) + { + return Forbid(); + } + + var model = await _manager.FindByIdAsync(id); + + if (model == null) + { + return NotFound(); + } + + if (await _manager.DeleteAsync(model)) + { + _shellReleaseManager.RequestRelease(); + + await _notifier.SuccessAsync(H["The credential has been deleted successfully."]); + } + else + { + await _notifier.ErrorAsync(H["Unable to remove the credential."]); + } + + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + [ActionName(nameof(Index))] + [FormValueRequired("submit.BulkAction")] + [Admin("security/credentials", "SecurityCredentials")] + public async Task IndexPost(CatalogEntryOptions options, IEnumerable itemIds) + { + if (!await _authorizationService.AuthorizeAsync(User, SecurityConstants.Permissions.ManageCredentials)) + { + return Forbid(); + } + + if (itemIds?.Count() > 0) + { + switch (options.BulkAction) + { + case CatalogEntryAction.None: + break; + case CatalogEntryAction.Remove: + var counter = 0; + foreach (var id in itemIds) + { + var instance = await _manager.FindByIdAsync(id); + + if (instance == null) + { + continue; + } + + if (await _manager.DeleteAsync(instance)) + { + counter++; + } + } + if (counter == 0) + { + await _notifier.WarningAsync(H["No credentials were removed."]); + } + else + { + _shellReleaseManager.RequestRelease(); + + await _notifier.SuccessAsync(H.Plural(counter, "1 credential has been removed successfully.", "{0} credentials have been removed successfully.")); + } + break; + default: + return BadRequest(); + } + } + + return RedirectToAction(nameof(Index)); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Security/CredentialsAdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Security/CredentialsAdminMenu.cs new file mode 100644 index 00000000000..619c6f01af5 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/CredentialsAdminMenu.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Localization; +using OrchardCore.Navigation; +using OrchardCore.Security.Core; + +namespace OrchardCore.Security; + +public sealed class CredentialsAdminMenu : AdminNavigationProvider +{ + internal readonly IStringLocalizer S; + + public CredentialsAdminMenu(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + protected override ValueTask BuildAsync(NavigationBuilder builder) + { + builder + .Add(S["Tools"], tools => tools + .Add(S["Credentials"], S["Credentials"].PrefixPosition(), security => security + .Permission(SecurityConstants.Permissions.ManageCredentials) + .Action("Index", "Credentials", SecurityConstants.Features.Area) + .LocalNav() + ) + ); + + return ValueTask.CompletedTask; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Security/CredentialsPermissions.cs b/src/OrchardCore.Modules/OrchardCore.Security/CredentialsPermissions.cs new file mode 100644 index 00000000000..eebc19f7c0c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/CredentialsPermissions.cs @@ -0,0 +1,24 @@ +using OrchardCore.Security.Core; +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Security; + +public sealed class CredentialsPermissions : IPermissionProvider +{ + private readonly IEnumerable _allPermissions = + [ + SecurityConstants.Permissions.ManageCredentials, + ]; + + public Task> GetPermissionsAsync() + => Task.FromResult(_allPermissions); + + public IEnumerable GetDefaultStereotypes() => + [ + new PermissionStereotype + { + Name = OrchardCoreConstants.Roles.Administrator, + Permissions = _allPermissions, + }, + ]; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Drivers/CredentialDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Security/Drivers/CredentialDisplayDriver.cs new file mode 100644 index 00000000000..20a9a222964 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Drivers/CredentialDisplayDriver.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Localization; +using OrchardCore.Catalogs; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.Security.Core; +using OrchardCore.Security.ViewModels; + +namespace CrestApps.OrchardCore.AI.Drivers; + +internal sealed class CredentialDisplayDriver : DisplayDriver +{ + private readonly INamedCatalog _catalog; + + internal readonly IStringLocalizer S; + + public CredentialDisplayDriver( + INamedCatalog catalog, + IStringLocalizer stringLocalizer) + { + _catalog = catalog; + S = stringLocalizer; + } + + public override Task DisplayAsync(Credential credential, BuildDisplayContext context) + { + return CombineAsync( + View("Credential_Fields_SummaryAdmin", credential).Location("Content:1"), + View("Credential_Buttons_SummaryAdmin", credential).Location("Actions:5"), + View("Credential_DefaultTags_SummaryAdmin", credential).Location("Tags:5"), + View("Credential_DefaultMeta_SummaryAdmin", credential).Location("Meta:5") + ); + } + + public override IDisplayResult Edit(Credential credential, BuildEditorContext context) + { + return Initialize("CredentialFields_Edit", model => + { + model.DisplayText = credential.DisplayText; + model.Name = credential.Name; + model.IsNew = context.IsNew; + }).Location("Content:1"); + } + + public override async Task UpdateAsync(Credential credential, UpdateEditorContext context) + { + var model = new CredentialFieldsViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + if (context.IsNew) + { + if (string.IsNullOrEmpty(model.Name)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Name), S["Name is required."]); + } + else if (await _catalog.FindByNameAsync(model.Name) is not null) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Name), S["Another credential with the same name exists."]); + } + + credential.Name = model.Name; + } + + if (string.IsNullOrWhiteSpace(model.DisplayText)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.DisplayText), S["The Display text is required."]); + } + + credential.DisplayText = model.DisplayText; + + return Edit(credential, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Drivers/SecuritySettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Security/Drivers/SecuritySettingsDisplayDriver.cs index 52c485b06fb..3781dedfd9c 100644 --- a/src/OrchardCore.Modules/OrchardCore.Security/Drivers/SecuritySettingsDisplayDriver.cs +++ b/src/OrchardCore.Modules/OrchardCore.Security/Drivers/SecuritySettingsDisplayDriver.cs @@ -7,6 +7,7 @@ using OrchardCore.DisplayManagement.Notify; using OrchardCore.DisplayManagement.Views; using OrchardCore.Environment.Shell; +using OrchardCore.Security.Core; using OrchardCore.Security.Options; using OrchardCore.Security.Settings; using OrchardCore.Security.ViewModels; @@ -49,7 +50,7 @@ public override async Task EditAsync(ISite site, SecuritySetting { var user = _httpContextAccessor.HttpContext?.User; - if (!await _authorizationService.AuthorizeAsync(user, SecurityPermissions.ManageSecurityHeadersSettings)) + if (!await _authorizationService.AuthorizeAsync(user, SecurityConstants.Permissions.ManageSecurityHeadersSettings)) { return null; } @@ -96,7 +97,7 @@ public override async Task UpdateAsync(ISite site, SecuritySetti { var user = _httpContextAccessor.HttpContext?.User; - if (!await _authorizationService.AuthorizeAsync(user, SecurityPermissions.ManageSecurityHeadersSettings)) + if (!await _authorizationService.AuthorizeAsync(user, SecurityConstants.Permissions.ManageSecurityHeadersSettings)) { return null; } diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Security/Manifest.cs index 12a8230d4d2..f9748ab421c 100644 --- a/src/OrchardCore.Modules/OrchardCore.Security/Manifest.cs +++ b/src/OrchardCore.Modules/OrchardCore.Security/Manifest.cs @@ -1,10 +1,24 @@ using OrchardCore.Modules.Manifest; +using OrchardCore.Security.Core; [assembly: Module( Name = "Security", Author = ManifestConstants.OrchardCoreTeam, Website = ManifestConstants.OrchardCoreWebsite, - Version = ManifestConstants.OrchardCoreVersion, + Version = ManifestConstants.OrchardCoreVersion +)] + +[assembly: Feature( + Id = SecurityConstants.Features.Area, + Name = "Security", Description = "The Security module adds HTTP headers to follow security best practices.", Category = "Security" )] + +[assembly: Feature( + Id = SecurityConstants.Features.Credentials, + Name = "Security Credentials", + Description = "Provides a way to securly manage reusable credentials.", + Category = "Security", + EnabledByDependencyOnly = true +)] diff --git a/src/OrchardCore.Modules/OrchardCore.Security/OrchardCore.Security.csproj b/src/OrchardCore.Modules/OrchardCore.Security/OrchardCore.Security.csproj index e0c17ffda29..9c1019a34d3 100644 --- a/src/OrchardCore.Modules/OrchardCore.Security/OrchardCore.Security.csproj +++ b/src/OrchardCore.Modules/OrchardCore.Security/OrchardCore.Security.csproj @@ -20,6 +20,7 @@ + diff --git a/src/OrchardCore.Modules/OrchardCore.Security/SecurityPermissions.cs b/src/OrchardCore.Modules/OrchardCore.Security/SecurityPermissions.cs index 1198fb3359e..ba1b69b7629 100644 --- a/src/OrchardCore.Modules/OrchardCore.Security/SecurityPermissions.cs +++ b/src/OrchardCore.Modules/OrchardCore.Security/SecurityPermissions.cs @@ -1,14 +1,16 @@ +using OrchardCore.Security.Core; using OrchardCore.Security.Permissions; namespace OrchardCore.Security; public sealed class SecurityPermissions : IPermissionProvider { - public static readonly Permission ManageSecurityHeadersSettings = new("ManageSecurityHeadersSettings", "Manage Security Headers Settings"); + [Obsolete("This will be removed in a future release. Instead use 'SecurityConstants.Permissions.ManageSecurityHeadersSettings'.")] + public static readonly Permission ManageSecurityHeadersSettings = SecurityConstants.Permissions.ManageSecurityHeadersSettings; private readonly IEnumerable _allPermissions = [ - ManageSecurityHeadersSettings, + SecurityConstants.Permissions.ManageSecurityHeadersSettings, ]; public Task> GetPermissionsAsync() diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Security/Startup.cs index 753cbabe759..984f228258a 100644 --- a/src/OrchardCore.Modules/OrchardCore.Security/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Security/Startup.cs @@ -1,10 +1,13 @@ +using CrestApps.OrchardCore.AI.Drivers; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using OrchardCore.Catalogs; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.Modules; using OrchardCore.Navigation; +using OrchardCore.Security.Core; using OrchardCore.Security.Drivers; using OrchardCore.Security.Permissions; using OrchardCore.Security.Services; @@ -25,6 +28,8 @@ public override void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddTransient, SecuritySettingsConfiguration>(); + + services.AddDisplayDriver(); } public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) @@ -41,3 +46,16 @@ public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilde }); } } + +[Feature(SecurityConstants.Features.Credentials)] +public sealed class CredentialsStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddTransient, SecurityOptionsConfiguration>(); + services.AddDisplayDriver(); + services.AddScoped, CredentialHandler>(); + services.AddNavigationProvider(); + services.AddPermissionProvider(); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Security/ViewModels/CredentialFieldsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Security/ViewModels/CredentialFieldsViewModel.cs new file mode 100644 index 00000000000..0d8d47c037f --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/ViewModels/CredentialFieldsViewModel.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.Security.ViewModels; + +public class CredentialFieldsViewModel +{ + public string DisplayText { get; set; } + + public string Name { get; set; } + public bool IsNew { get; internal set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.Edit.cshtml new file mode 100644 index 00000000000..ad1bf3a8cc6 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.Edit.cshtml @@ -0,0 +1 @@ +@await DisplayAsync(Model.Content) diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.Fields.SummaryAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.Fields.SummaryAdmin.cshtml new file mode 100644 index 00000000000..e3c2a79d719 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.Fields.SummaryAdmin.cshtml @@ -0,0 +1,6 @@ +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.Security.Core + +@model ShapeViewModel + +
@(Model.Value.DisplayText)
diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.Link.cshtml b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.Link.cshtml new file mode 100644 index 00000000000..5c5673edc4b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.Link.cshtml @@ -0,0 +1,29 @@ +@using Microsoft.Extensions.Options +@using OrchardCore.Security.Core + +@inject IOptions SecurityOptions +@{ + string providerName = Model.ProviderName; + + if (!SecurityOptions.Value.CredentialProviders.TryGetValue(providerName, out var provider)) + { + return; + } +} + +
+
+
+

@(provider.DisplayName)

+

@(provider.Description)

+ +
+ + + +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.SummaryAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.SummaryAdmin.cshtml new file mode 100644 index 00000000000..64e92cd317b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credential.SummaryAdmin.cshtml @@ -0,0 +1,46 @@ +
+
+ +
+
+ @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } +
+ + @if (Model.Tags != null) + { +
+ @await DisplayAsync(Model.Tags) +
+ } + @if (Model.Meta != null) + { + + } +
+
+
+
+ @if (Model.Actions != null) + { + @await DisplayAsync(Model.Actions) + } + + @if (Model.ActionsMenu != null && Model.ActionsMenu.HasItems) + { +
+ + +
+ } +
+
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Views/CredentialFields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Security/Views/CredentialFields.Edit.cshtml new file mode 100644 index 00000000000..2d2a5d22b10 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Views/CredentialFields.Edit.cshtml @@ -0,0 +1,29 @@ +@using OrchardCore +@using OrchardCore.Security.ViewModels + +@model CredentialFieldsViewModel + +
+ +
+ + + @if (Model.IsNew) + { + @T["Credential name. This name cannot be changed later."] + } + else + { + @T["Credential name."] + } +
+
+ +
+ +
+ + + @T["The display text for the connection."] +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Views/Credentials/Create.cshtml b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credentials/Create.cshtml new file mode 100644 index 00000000000..e5ec6caaf09 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credentials/Create.cshtml @@ -0,0 +1,15 @@ +@using OrchardCore.Catalogs.Models +@model EditCatalogEntryViewModel + + +

@RenderTitleSegments(T["New '{0}' Credential", Model.DisplayName])

+
+ +
+ @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + + + + @T["Cancel"] +
diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Views/Credentials/Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credentials/Edit.cshtml new file mode 100644 index 00000000000..45b9555340b --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credentials/Edit.cshtml @@ -0,0 +1,16 @@ +@using OrchardCore.Catalogs.Models + +@model EditCatalogEntryViewModel + + +

@RenderTitleSegments(T["Edit '{0}' Credential", Model.DisplayName])

+
+ +
+ @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + + + + @T["Cancel"] +
diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Views/Credentials/Index.cshtml b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credentials/Index.cshtml new file mode 100644 index 00000000000..fed4d30c7b9 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Views/Credentials/Index.cshtml @@ -0,0 +1,139 @@ +@using Microsoft.AspNetCore.Mvc.Localization +@using OrchardCore.Catalogs.Models +@using OrchardCore.DisplayManagement +@using OrchardCore.Security.Core + +@model ListSourceCatalogEntryViewModel + +

@RenderTitleSegments(T["Credentials"])

+ +@* The form is necessary to generate an antiforgery token for the delete and toggle actions. *@ +
+ + + + +
+
+
+
+ +
+
+ +
+
+
+
+ +
    + @if (Model.Models.Count > 0) + { + int startIndex = 0; + int endIndex = startIndex + Model.Models.Count - 1; + +
  • +
    +
    +
    + + + + +
    +
    +
    + +
    +
    +
  • + @foreach (var entry in Model.Models) + { +
  • +
    +
    + + +
    +
    + +
    + @await DisplayAsync(entry.Shape) +
    +
  • + } + } + else + { +
  • + @T["Nothing here! There are no credentials at the moment."] +
  • + } +
+ + + +
+ + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Views/Items/Credential.Buttons.SummaryAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.Security/Views/Items/Credential.Buttons.SummaryAdmin.cshtml new file mode 100644 index 00000000000..17d9cdf1afa --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Views/Items/Credential.Buttons.SummaryAdmin.cshtml @@ -0,0 +1,13 @@ +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.Security.Core + +@model ShapeViewModel + +@T["Edit"] + +@T["Delete"] diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Views/Items/Credential.DefaultMeta.SummaryAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.Security/Views/Items/Credential.DefaultMeta.SummaryAdmin.cshtml new file mode 100644 index 00000000000..b4fb61f088e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Views/Items/Credential.DefaultMeta.SummaryAdmin.cshtml @@ -0,0 +1,21 @@ +@using System.Globalization +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.Security.Core + +@model ShapeViewModel + +@{ + var createdAt = Model.Value.CreatedUtc.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture); +} + + + + + + +@if (!string.IsNullOrEmpty(Model.Value.Author)) +{ + + @Model.Value.Author + +} diff --git a/src/OrchardCore.Modules/OrchardCore.Security/Views/Items/Credential.DefaultTags.SummaryAdmin.cshtml b/src/OrchardCore.Modules/OrchardCore.Security/Views/Items/Credential.DefaultTags.SummaryAdmin.cshtml new file mode 100644 index 00000000000..c2a41a9f355 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Security/Views/Items/Credential.DefaultTags.SummaryAdmin.cshtml @@ -0,0 +1,8 @@ +@using OrchardCore.DisplayManagement.Views +@using OrchardCore.Security.Core + +@model ShapeViewModel + + + @Model.Value.Source + diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Models/AzureSmsOptions.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Models/AzureSmsOptions.cs index 8a0407dcb42..2abf450ae68 100644 --- a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Models/AzureSmsOptions.cs +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Models/AzureSmsOptions.cs @@ -8,7 +8,22 @@ public class AzureSmsOptions public string ConnectionString { get; set; } + public Uri Endpoint { get; set; } + + public string CredentialName { get; set; } + public bool ConfigurationExists() - => !string.IsNullOrWhiteSpace(PhoneNumber) && - !string.IsNullOrWhiteSpace(ConnectionString); + { + if (string.IsNullOrWhiteSpace(PhoneNumber)) + { + return false; + } + + if (Endpoint is not null) + { + return true; + } + + return !string.IsNullOrWhiteSpace(ConnectionString); + } } diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsOptionsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsOptionsConfiguration.cs index f7c913290cb..a516262d300 100644 --- a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsOptionsConfiguration.cs +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsOptionsConfiguration.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Settings; using OrchardCore.Sms.Azure.Models; @@ -31,7 +32,19 @@ public void Configure(AzureSmsOptions options) { var protector = _dataProtectionProvider.CreateProtector(ProtectorName); - options.ConnectionString = protector.Unprotect(settings.ConnectionString); + var rawConnectionString = protector.Unprotect(settings.ConnectionString); + + options.ConnectionString = rawConnectionString; + + if (options.Endpoint is null) + { + var endpointString = ConnectionStringHelper.Extract(rawConnectionString, "Endpoint"); + + if (endpointString is not null && Uri.TryCreate(endpointString, UriKind.Absolute, out var endpointUri)) + { + options.Endpoint = endpointUri; + } + } } } } diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProvider.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProvider.cs index fe0487fe924..9f25f9182bf 100644 --- a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProvider.cs +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProvider.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Sms.Azure.Models; namespace OrchardCore.Sms.Azure.Services; @@ -12,9 +13,10 @@ public sealed class AzureSmsProvider : AzureSmsProviderBase public AzureSmsProvider( IOptions options, IPhoneFormatValidator phoneFormatValidator, + IOptionsMonitor optionsMonitor, ILogger logger, IStringLocalizer stringLocalizer) - : base(options.Value, phoneFormatValidator, logger, stringLocalizer) + : base(options.Value, phoneFormatValidator, optionsMonitor, logger, stringLocalizer) { } diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProviderBase.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProviderBase.cs index 789ca2c2481..5371cc31a18 100644 --- a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProviderBase.cs +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProviderBase.cs @@ -1,6 +1,8 @@ using Azure.Communication.Sms; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Sms.Azure.Models; namespace OrchardCore.Sms.Azure.Services; @@ -9,6 +11,7 @@ public abstract class AzureSmsProviderBase : ISmsProvider { private readonly AzureSmsOptions _providerOptions; private readonly IPhoneFormatValidator _phoneFormatValidator; + private readonly IOptionsMonitor _optionsMonitor; private readonly ILogger _logger; private SmsClient _smsClient; @@ -18,11 +21,13 @@ public abstract class AzureSmsProviderBase : ISmsProvider public AzureSmsProviderBase( AzureSmsOptions options, IPhoneFormatValidator phoneFormatValidator, + IOptionsMonitor optionsMonitor, ILogger logger, IStringLocalizer stringLocalizer) { _providerOptions = options; _phoneFormatValidator = phoneFormatValidator; + _optionsMonitor = optionsMonitor; _logger = logger; S = stringLocalizer; } @@ -57,7 +62,28 @@ public virtual async Task SendAsync(SmsMessage message) try { - _smsClient ??= new SmsClient(_providerOptions.ConnectionString); + if (_smsClient is null) + { + if (!string.IsNullOrEmpty(_providerOptions.ConnectionString)) + { + _smsClient ??= new SmsClient(_providerOptions.ConnectionString); + } + else if (_providerOptions.Endpoint is not null) + { + var azureOptions = _optionsMonitor.Get(_providerOptions.CredentialName ?? AzureOptions.DefaultName); + + if (azureOptions is null) + { + return SmsResult.Failed(S["Unsupported Authentication Type. The Azure SMS Provider is not configured correctly."]); + } + + _smsClient = new SmsClient(_providerOptions.Endpoint, azureOptions.ToTokenCredential()); + } + else + { + return SmsResult.Failed(S["The Azure SMS Provider is not configured correctly."]); + } + } var senderNumber = _providerOptions.PhoneNumber; diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/DefaultAzureSmsProvider.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/DefaultAzureSmsProvider.cs index b78df0cf4ca..87d851b77ca 100644 --- a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/DefaultAzureSmsProvider.cs +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/DefaultAzureSmsProvider.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Sms.Azure.Models; namespace OrchardCore.Sms.Azure.Services; @@ -12,9 +13,10 @@ public sealed class DefaultAzureSmsProvider : AzureSmsProviderBase public DefaultAzureSmsProvider( IOptions options, IPhoneFormatValidator phoneFormatValidator, + IOptionsMonitor optionsMonitor, ILogger logger, IStringLocalizer stringLocalizer) - : base(options.Value, phoneFormatValidator, logger, stringLocalizer) + : base(options.Value, phoneFormatValidator, optionsMonitor, logger, stringLocalizer) { } diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Startup.cs index 8b899739ac3..3ee78a02764 100644 --- a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Startup.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Azure.Core; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.Environment.Shell.Configuration; using OrchardCore.Modules; @@ -26,6 +27,16 @@ public override void ConfigureServices(IServiceCollection services) { _shellConfiguration.GetSection("OrchardCore_Sms_AzureCommunicationServices").Bind(options); + if (options.Endpoint is null && !string.IsNullOrEmpty(options.ConnectionString)) + { + var endpointString = ConnectionStringHelper.Extract(options.ConnectionString, "Endpoint"); + + if (endpointString is not null && Uri.TryCreate(endpointString, UriKind.Absolute, out var endpointUri)) + { + options.Endpoint = endpointUri; + } + } + options.IsEnabled = options.ConfigurationExists(); }); } diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICatalog.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICatalog.cs new file mode 100644 index 00000000000..46054d5a4d5 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICatalog.cs @@ -0,0 +1,40 @@ +namespace OrchardCore.Catalogs; + +public interface ICatalog : IReadCatalog +{ + /// + /// Asynchronously deletes the specified entry from the catalog. + /// + /// The entry to delete. Must not be null. + /// + /// A representing the asynchronous operation. + /// The result is true if the deletion was successful, false if the entry does not exist or could not be deleted. + /// + ValueTask DeleteAsync(T entry); + + /// + /// Asynchronously creates the specified entry in the catalog. + /// + /// The entry to create. Must not be null. + /// + /// A representing the asynchronous operation. No result is returned. + /// + ValueTask CreateAsync(T entry); + + /// + /// Asynchronously updates the specified entry in the catalog. + /// + /// The entry to update. Must not be null. + /// + /// A representing the asynchronous operation. No result is returned. + /// + ValueTask UpdateAsync(T entry); + + /// + /// Asynchronously saves all pending changes in the catalog. + /// + /// + /// A representing the asynchronous operation. No result is returned. + /// + ValueTask SaveChangesAsync(); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/IModelHandler.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICatalogEntryHandler.cs similarity index 72% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/IModelHandler.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICatalogEntryHandler.cs index c7d0383244b..097d61b3e34 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/IModelHandler.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICatalogEntryHandler.cs @@ -1,69 +1,71 @@ -namespace OrchardCore.Infrastructure.Entities; +using OrchardCore.Catalogs.Models; -public interface IModelHandler +namespace OrchardCore.Catalogs; + +public interface ICatalogEntryHandler { /// - /// This method in invoked during model initializing. + /// This method is invoked during model initializing. /// /// An instance of . Task InitializingAsync(InitializingContext context); /// - /// This method in invoked after the model was initialized. + /// This method is invoked after the model was initialized. /// /// An instance of . Task InitializedAsync(InitializedContext context); /// - /// This method in invoked after the model was loaded from the store. + /// This method is invoked after the model was loaded from the store. /// /// An instance of . Task LoadedAsync(LoadedContext context); /// - /// This method in invoked during model validating. + /// This method is invoked during model validating. /// /// An instance of . Task ValidatingAsync(ValidatingContext context); /// - /// This method in invoked after the model was validated. + /// This method is invoked after the model was validated. /// /// An instance of . Task ValidatedAsync(ValidatedContext context); /// - /// This method in invoked during model removing. + /// This method is invoked during model removing. /// /// An instance of . Task DeletingAsync(DeletingContext context); /// - /// This method in invoked after the model was removed. + /// This method is invoked after the model was removed. /// /// An instance of . Task DeletedAsync(DeletedContext context); /// - /// This method in invoked during model updating. + /// This method is invoked during model updating. /// /// An instance of . Task UpdatingAsync(UpdatingContext context); /// - /// This method in invoked after the model was updated. + /// This method is invoked after the model was updated. /// /// An instance of . Task UpdatedAsync(UpdatedContext context); /// - /// This method in invoked during model saving. + /// This method is invoked during model saving. /// /// An instance of . Task CreatingAsync(CreatingContext context); /// - /// This method in invoked after the model was saved. + /// This method is invoked after the model was saved. /// /// An instance of . Task CreatedAsync(CreatedContext context); diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICatalogManager.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICatalogManager.cs new file mode 100644 index 00000000000..3c5fd4860f2 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICatalogManager.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Nodes; +using OrchardCore.Catalogs.Models; + +namespace OrchardCore.Catalogs; + +public interface ICatalogManager : IReadCatalogManager +{ + /// + /// Asynchronously deletes the specified model. + /// + /// The model to be deleted. + /// + /// A that represents the asynchronous operation. + /// The result is true if the deletion was successful, false otherwise. + /// + ValueTask DeleteAsync(T model); + + /// + /// Asynchronously creates a new model with optional additional data. + /// + /// Optional additional data associated with the model. Defaults to null. + /// + /// A that represents the asynchronous operation. + /// The result is the newly created model. + /// + ValueTask NewAsync(JsonNode data = null); + + /// + /// Asynchronously creates the given model. + /// + /// The model to be created. + /// + /// A that represents the asynchronous operation. No result is returned. + /// + ValueTask CreateAsync(T model); + + /// + /// Asynchronously updates the specified model with optional additional data. + /// + /// The model to be updated. + /// Optional additional data to update the model with. Defaults to null. + /// + /// A that represents the asynchronous operation. No result is returned. + /// + ValueTask UpdateAsync(T model, JsonNode data = null); + + /// + /// Asynchronously validates the specified model. + /// + /// The model to be validated. + /// + /// A that represents the asynchronous operation. + /// The result is a indicating whether the model is valid. + /// + ValueTask ValidateAsync(T model); +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICloneable.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICloneable.cs new file mode 100644 index 00000000000..122f6fc86af --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ICloneable.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.Catalogs; + +public interface ICloneable : ICloneable +{ + new T Clone(); + + object ICloneable.Clone() + => Clone(); +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/IDisplayTextAwareModel.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/IDisplayTextAwareModel.cs new file mode 100644 index 00000000000..910381613bc --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/IDisplayTextAwareModel.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Catalogs; + +public interface IDisplayTextAwareModel +{ + string DisplayText { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INameAwareModel.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INameAwareModel.cs new file mode 100644 index 00000000000..cce602eb83f --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INameAwareModel.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Catalogs; + +public interface INameAwareModel +{ + string Name { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedCatalog.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedCatalog.cs new file mode 100644 index 00000000000..faaa2f92a32 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedCatalog.cs @@ -0,0 +1,15 @@ +namespace OrchardCore.Catalogs; + +public interface INamedCatalog : ICatalog + where T : INameAwareModel +{ + /// + /// Asynchronously retrieves a entry by its unique name. + /// + /// The unique name of the entry. Must not be null or empty. + /// + /// A representing the asynchronous operation. + /// The result is the entry if found, or null if no entry with the specified name exists in the catalog. + /// + ValueTask FindByNameAsync(string name); +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedCatalogManager.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedCatalogManager.cs new file mode 100644 index 00000000000..b2905884fea --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedCatalogManager.cs @@ -0,0 +1,15 @@ +namespace OrchardCore.Catalogs; + +public interface INamedCatalogManager : ICatalogManager + where T : INameAwareModel +{ + /// + /// Asynchronously retrieves a model by its name. + /// + /// The name of the model. Must not be null or empty. + /// + /// A representing the asynchronous operation. + /// The result is the model matching the specified name, or null if no model is found. + /// + ValueTask FindByNameAsync(string name); +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedSourceCatalog.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedSourceCatalog.cs new file mode 100644 index 00000000000..aa684ce8b71 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedSourceCatalog.cs @@ -0,0 +1,16 @@ +namespace OrchardCore.Catalogs; + +public interface INamedSourceCatalog : INamedCatalog, ISourceCatalog + where T : INameAwareModel, ISourceAwareModel +{ + /// + /// Asynchronously retrieves a entry by its unique name and source. + /// + /// The unique name of the entry. Must not be null or empty. + /// The source of the entry. Must not be null or empty. + /// + /// A representing the asynchronous operation. + /// The result is the entry if found, or null if no entry with the specified name and source exists in the catalog. + /// + ValueTask GetAsync(string name, string source); +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedSourceCatalogManager.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedSourceCatalogManager.cs new file mode 100644 index 00000000000..ecbb9c09506 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/INamedSourceCatalogManager.cs @@ -0,0 +1,16 @@ +namespace OrchardCore.Catalogs; + +public interface INamedSourceCatalogManager : INamedCatalogManager, ISourceCatalogManager + where T : INameAwareModel, ISourceAwareModel +{ + /// + /// Asynchronously retrieves a model by its name and source. + /// + /// The unique name of the model. Must not be null or empty. + /// The unique identifier of the source provider. Must not be null or empty. + /// + /// A representing the asynchronous operation. + /// The result is the model matching the specified name and source, or null if no such model exists. + /// + ValueTask GetAsync(string name, string source); +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/IReadCatalog.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/IReadCatalog.cs new file mode 100644 index 00000000000..7239afe0b95 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/IReadCatalog.cs @@ -0,0 +1,49 @@ +using OrchardCore.Catalogs.Models; + +namespace OrchardCore.Catalogs; + +public interface IReadCatalog +{ + /// + /// Asynchronously retrieves a entry by its unique identifier. + /// + /// The unique identifier of the entry. Must not be null or empty. + /// + /// A representing the asynchronous operation. + /// The result is the entry if found, or null if no matching entry exists in the catalog. + /// + ValueTask FindByIdAsync(string id); + + /// + /// Asynchronously retrieves all entry from the catalog. + /// + /// + /// A representing the asynchronous operation. + /// The result is an containing all entry in the catalog. + /// + ValueTask> GetAllAsync(); + + /// + /// Asynchronously retrieves all entry from the catalog. + /// + /// + /// The ids to retrieve. + /// A representing the asynchronous operation. + /// The result is an containing all entry in the catalog. + /// + ValueTask> GetAsync(IEnumerable ids); + + /// + /// Asynchronously retrieves a paginated list of entry based on the specified pagination and filtering parameters. + /// + /// The type of the query context used for filtering, sorting, and other query options. + /// The page number to retrieve (1-based index). + /// The number of entry to retrieve per page. + /// The query context containing filtering, sorting, and search parameters. Can be null. + /// + /// A representing the asynchronous operation. + /// The result is a containing the entry for the requested page, along with pagination metadata. + /// + ValueTask> PageAsync(int page, int pageSize, TQuery context) + where TQuery : QueryContext; +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/IReadCatalogManager.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/IReadCatalogManager.cs new file mode 100644 index 00000000000..05649b0833e --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/IReadCatalogManager.cs @@ -0,0 +1,39 @@ +using OrchardCore.Catalogs.Models; + +namespace OrchardCore.Catalogs; + +public interface IReadCatalogManager +{ + /// + /// Asynchronously retrieves a model by its unique identifier. + /// + /// The unique identifier of the model. + /// + /// A that represents the asynchronous operation. + /// The result is the model corresponding to the specified ID, or null if not found. + /// + ValueTask FindByIdAsync(string id); + + /// + /// Asynchronously retrieves a list of all models. + /// + /// + /// A that represents the asynchronous operation. + /// The result is an containing all models. + /// + ValueTask> GetAllAsync(); + + /// + /// Asynchronously retrieves a paginated list of models. + /// + /// The type of the query context used for filtering, sorting, and other query options. + /// The page number of the results to retrieve. + /// The number of results per page. + /// The query context containing filtering, sorting, and other parameters. + /// + /// A that represents the asynchronous operation. + /// The result is a containing the paginated query results. + /// + ValueTask> PageAsync(int page, int pageSize, TQuery context) + where TQuery : QueryContext; +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ISourceAwareModel.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ISourceAwareModel.cs new file mode 100644 index 00000000000..a9264509f3c --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ISourceAwareModel.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Catalogs; + +public interface ISourceAwareModel +{ + string Source { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ISourceCatalog.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ISourceCatalog.cs new file mode 100644 index 00000000000..e647154484c --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ISourceCatalog.cs @@ -0,0 +1,15 @@ +namespace OrchardCore.Catalogs; + +public interface ISourceCatalog : ICatalog + where T : ISourceAwareModel +{ + /// + /// Asynchronously retrieves all entries associated with the specified source. + /// + /// The source of the entries. Must not be null or empty. + /// + /// A representing the asynchronous operation. + /// The result is a collection of entries associated with the given source. + /// + ValueTask> GetAsync(string source); +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ISourceCatalogManager.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ISourceCatalogManager.cs new file mode 100644 index 00000000000..703bf36bb06 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/ISourceCatalogManager.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Nodes; + +namespace OrchardCore.Catalogs; + +public interface ISourceCatalogManager : ICatalogManager + where T : ISourceAwareModel +{ + /// + /// Asynchronously creates a new model with the given source and optional additional data. + /// + /// The source from which the model is created. Must not be null or empty. + /// Optional additional data associated with the model. Defaults to null. + /// + /// A representing the asynchronous operation. + /// The result is the newly created model. + /// + ValueTask NewAsync(string source, JsonNode data = null); + + /// + /// Asynchronously retrieves all models associated with the specified source. + /// + /// The source of the models. Must not be null or empty. + /// + /// A representing the asynchronous operation. + /// The result is a collection of models associated with the given source. + /// + ValueTask> GetAsync(string source); + + /// + /// Asynchronously retrieves all models associated with the specified source. + /// + /// The unique identifier of the source. Must not be null or empty. + /// + /// A representing the asynchronous operation. + /// The result is a collection of models associated with the specified source. + /// + ValueTask> FindBySourceAsync(string source); +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/CreatedContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/CreatedContext.cs similarity index 74% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/CreatedContext.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/CreatedContext.cs index 3d828f8c9ce..d9ffb6a12ce 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/CreatedContext.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/CreatedContext.cs @@ -1,4 +1,4 @@ -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public sealed class CreatedContext : HandlerContextBase { diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/CreatingContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/CreatingContext.cs similarity index 74% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/CreatingContext.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/CreatingContext.cs index 3adf5d770a7..fa247fe6e6f 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/CreatingContext.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/CreatingContext.cs @@ -1,4 +1,4 @@ -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public sealed class CreatingContext : HandlerContextBase { diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/DeletedContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/DeletedContext.cs new file mode 100644 index 00000000000..cffc588d484 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/DeletedContext.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.Catalogs.Models; + +public sealed class DeletedContext : HandlerContextBase +{ + public DeletedContext(T entry) + : base(entry) + { + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/DeletingContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/DeletingContext.cs similarity index 74% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/DeletingContext.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/DeletingContext.cs index b0c9620e4c5..2ec95380150 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/DeletingContext.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/DeletingContext.cs @@ -1,4 +1,4 @@ -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public sealed class DeletingContext : HandlerContextBase { diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/HandlerContextBase.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/HandlerContextBase.cs similarity index 81% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/HandlerContextBase.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/HandlerContextBase.cs index d1b3745c0ca..0aa2af88717 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/HandlerContextBase.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/HandlerContextBase.cs @@ -1,4 +1,4 @@ -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public abstract class HandlerContextBase { diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/InitializedContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/InitializedContext.cs similarity index 75% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/InitializedContext.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/InitializedContext.cs index 774fe72e15a..c5621a16137 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/InitializedContext.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/InitializedContext.cs @@ -1,4 +1,4 @@ -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public sealed class InitializedContext : HandlerContextBase { diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/InitializingContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/InitializingContext.cs similarity index 85% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/InitializingContext.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/InitializingContext.cs index 6e256da833e..8a77810963d 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/InitializingContext.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/InitializingContext.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public sealed class InitializingContext : HandlerContextBase { diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/LoadedContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/LoadedContext.cs similarity index 74% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/LoadedContext.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/LoadedContext.cs index 0b13b581a6a..c8a87fd4e36 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/LoadedContext.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/LoadedContext.cs @@ -1,4 +1,4 @@ -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public sealed class LoadedContext : HandlerContextBase { diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/PageResult.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/PageResult.cs new file mode 100644 index 00000000000..14a5338c291 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/PageResult.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Catalogs.Models; + +public class PageResult +{ + public int Count { get; set; } + + public IReadOnlyCollection Entries { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/QueryContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/QueryContext.cs new file mode 100644 index 00000000000..41d700e9800 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/QueryContext.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Catalogs.Models; + +public class QueryContext +{ + public string Source { get; set; } + + public string Name { get; set; } + + public bool Sorted { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/UpdatedContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/UpdatedContext.cs similarity index 74% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/UpdatedContext.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/UpdatedContext.cs index a0092c63cbf..1970198e902 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/UpdatedContext.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/UpdatedContext.cs @@ -1,4 +1,4 @@ -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public sealed class UpdatedContext : HandlerContextBase { diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/UpdatingContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/UpdatingContext.cs similarity index 84% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/UpdatingContext.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/UpdatingContext.cs index 989d39a57ca..c1e9f7b9b7c 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/UpdatingContext.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/UpdatingContext.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public sealed class UpdatingContext : HandlerContextBase { diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ValidatedContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/ValidatedContext.cs similarity index 85% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ValidatedContext.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/ValidatedContext.cs index 1ef135f54d3..d324b5ddedc 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ValidatedContext.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/ValidatedContext.cs @@ -1,4 +1,4 @@ -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public sealed class ValidatedContext : HandlerContextBase { diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ValidatingContext.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/ValidatingContext.cs similarity index 81% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ValidatingContext.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/ValidatingContext.cs index 44614db8b7f..aa7cbc30250 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ValidatingContext.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/ValidatingContext.cs @@ -1,4 +1,4 @@ -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public sealed class ValidatingContext : HandlerContextBase { diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ValidationResultDetails.cs b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/ValidationResultDetails.cs similarity index 91% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ValidationResultDetails.cs rename to src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/ValidationResultDetails.cs index 7d60f260018..fc2106a79c6 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ValidationResultDetails.cs +++ b/src/OrchardCore/OrchardCore.Abstractions/Catalogs/Models/ValidationResultDetails.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace OrchardCore.Infrastructure.Entities; +namespace OrchardCore.Catalogs.Models; public class ValidationResultDetails { @@ -23,3 +23,4 @@ public void Fail(ValidationResult error) _errors.Add(error); } } + diff --git a/src/OrchardCore/OrchardCore.Abstractions/ITokenProvider.cs b/src/OrchardCore/OrchardCore.Abstractions/ITokenProvider.cs new file mode 100644 index 00000000000..34e3290639e --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/ITokenProvider.cs @@ -0,0 +1,6 @@ +namespace OrchardCore; + +public interface ITokenProvider +{ + Task GetTokenAsync(); +} diff --git a/src/OrchardCore/OrchardCore.Abstractions/TokenResult.cs b/src/OrchardCore/OrchardCore.Abstractions/TokenResult.cs new file mode 100644 index 00000000000..dd35d9eb1b0 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Abstractions/TokenResult.cs @@ -0,0 +1,6 @@ +namespace OrchardCore; + +public class TokenResult +{ + public string Token { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj index f98984a6726..4f08186e43c 100644 --- a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj +++ b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj @@ -46,6 +46,7 @@ + @@ -98,6 +99,7 @@ + diff --git a/src/OrchardCore/OrchardCore.Azure.Core/AzureAuthenticationType.cs b/src/OrchardCore/OrchardCore.Azure.Core/AzureAuthenticationType.cs new file mode 100644 index 00000000000..006310643b2 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Azure.Core/AzureAuthenticationType.cs @@ -0,0 +1,18 @@ +namespace OrchardCore.Azure.Core; + +public enum AzureAuthenticationType +{ + // Token Credential Types + Default, + ManagedIdentity, + AzureCli, + VisualStudio, + VisualStudioCode, + AzurePowerShell, + Environment, + InteractiveBrowser, + WorkloadIdentity, + + // Non-token Credential Types + ApiKey, +} diff --git a/src/OrchardCore/OrchardCore.Azure.Core/AzureOptions.cs b/src/OrchardCore/OrchardCore.Azure.Core/AzureOptions.cs new file mode 100644 index 00000000000..75af3e34ae4 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Azure.Core/AzureOptions.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Azure.Core; +using Azure.Identity; + +namespace OrchardCore.Azure.Core; + +public class AzureOptions +{ + public const string DefaultName = "Default"; + + public AzureAuthenticationType AuthenticationType { get; set; } + + public string TenantId { get; set; } + + public string ClientId { get; set; } + + public string ApiKey { get; set; } + + public JsonObject Properties { get; set; } + + public T GetProperty(string propertyName) + { + if (Properties is not null && Properties.TryGetPropertyValue(propertyName, out var propertyValue) && propertyValue is not null) + { + try + { + return propertyValue.Deserialize(); + } + catch { } + } + + return default; + } + + public virtual bool ConfigurationExists() + { + return AuthenticationType switch + { + AzureAuthenticationType.ApiKey => !string.IsNullOrEmpty(ApiKey), + _ => true, + }; + } + + public TokenCredential ToTokenCredential() + { + return AuthenticationType switch + { + AzureAuthenticationType.Default => new DefaultAzureCredential(), + AzureAuthenticationType.ManagedIdentity => new ManagedIdentityCredential(ClientId), + AzureAuthenticationType.AzureCli => new AzureCliCredential(), + AzureAuthenticationType.VisualStudio => new VisualStudioCredential(), + AzureAuthenticationType.VisualStudioCode => new VisualStudioCodeCredential(), + AzureAuthenticationType.AzurePowerShell => new AzurePowerShellCredential(), + AzureAuthenticationType.Environment => new EnvironmentCredential(), + AzureAuthenticationType.InteractiveBrowser => + new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions + { + TenantId = TenantId, + ClientId = ClientId, + }), + AzureAuthenticationType.WorkloadIdentity => new WorkloadIdentityCredential(), + _ => null, // ApiKey and unsupported types + }; + } +} diff --git a/src/OrchardCore/OrchardCore.Azure.Core/ConnectionStringHelper.cs b/src/OrchardCore/OrchardCore.Azure.Core/ConnectionStringHelper.cs new file mode 100644 index 00000000000..55a8dd4dbd8 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Azure.Core/ConnectionStringHelper.cs @@ -0,0 +1,24 @@ +using System.Data.Common; + +namespace OrchardCore.Azure.Core; + +public static class ConnectionStringHelper +{ + public static string Extract(string connectionString, string valueKey) + { + ArgumentNullException.ThrowIfNull(connectionString); + ArgumentNullException.ThrowIfNull(valueKey); + + var builder = new DbConnectionStringBuilder + { + ConnectionString = connectionString, + }; + + if (builder.TryGetValue(valueKey, out var value)) + { + return value?.ToString(); + } + + return null; + } +} diff --git a/src/OrchardCore/OrchardCore.Azure.Core/OrchardCore.Azure.Core.csproj b/src/OrchardCore/OrchardCore.Azure.Core/OrchardCore.Azure.Core.csproj new file mode 100644 index 00000000000..149846b397e --- /dev/null +++ b/src/OrchardCore/OrchardCore.Azure.Core/OrchardCore.Azure.Core.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/OrchardCore/OrchardCore.Azure.Core/ServiceCollectionExtensions.cs b/src/OrchardCore/OrchardCore.Azure.Core/ServiceCollectionExtensions.cs new file mode 100644 index 00000000000..269651a63c2 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Azure.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace OrchardCore.Azure.Core; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddAzureOptions(this IServiceCollection services) + { + services.TryAddSingleton, AzureOptionsConfigurations>(); + + return services; + } +} diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs index 38cbbce861c..754ad31296a 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs @@ -3,6 +3,8 @@ using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Modules; namespace OrchardCore.FileStorage.AzureBlob; @@ -37,20 +39,23 @@ public class BlobFileStore : IFileStore private readonly BlobStorageOptions _options; private readonly IClock _clock; - private readonly BlobContainerClient _blobContainer; + private readonly IOptionsMonitor _optionsMonitor; private readonly IContentTypeProvider _contentTypeProvider; - private readonly string _basePrefix; + private BlobContainerClient _blobContainer; + public BlobFileStore( BlobStorageOptions options, IClock clock, + IOptionsMonitor optionsMonitor, IContentTypeProvider contentTypeProvider) { _options = options; _clock = clock; + _optionsMonitor = optionsMonitor; _contentTypeProvider = contentTypeProvider; - _blobContainer = new BlobContainerClient(_options.ConnectionString, _options.ContainerName); + if (!string.IsNullOrEmpty(_options.BasePath)) { @@ -127,7 +132,7 @@ private async IAsyncEnumerable GetDirectoryContentByHierarchyAs var prefix = this.Combine(_basePrefix, path); prefix = NormalizePrefix(prefix); - var page = _blobContainer.GetBlobsByHierarchyAsync(BlobTraits.Metadata, BlobStates.None, "/", prefix); + var page = GetBlobContainer().GetBlobsByHierarchyAsync(BlobTraits.Metadata, BlobStates.None, "/", prefix); await foreach (var blob in page) { @@ -164,7 +169,7 @@ private async IAsyncEnumerable GetDirectoryContentFlatAsync(str var prefix = this.Combine(_basePrefix, path); prefix = NormalizePrefix(prefix); - var page = _blobContainer.GetBlobsAsync(BlobTraits.Metadata, BlobStates.None, prefix); + var page = GetBlobContainer().GetBlobsAsync(BlobTraits.Metadata, BlobStates.None, prefix); await foreach (var blob in page) { var name = WebUtility.UrlDecode(blob.Name); @@ -261,10 +266,10 @@ public async Task TryDeleteDirectoryAsync(string path) var prefix = this.Combine(_basePrefix, path); prefix = NormalizePrefix(prefix); - var page = _blobContainer.GetBlobsAsync(BlobTraits.Metadata, BlobStates.None, prefix); + var page = GetBlobContainer().GetBlobsAsync(BlobTraits.Metadata, BlobStates.None, prefix); await foreach (var blob in page) { - var blobReference = _blobContainer.GetBlobClient(blob.Name); + var blobReference = GetBlobContainer().GetBlobClient(blob.Name); await blobReference.DeleteIfExistsAsync(DeleteSnapshotsOption.IncludeSnapshots); blobsWereDeleted = true; } @@ -409,7 +414,7 @@ public async Task CreateFileFromStreamAsync(string path, Stream inputStr private BlobClient GetBlobReference(string path) { var blobPath = this.Combine(_options.BasePath, path); - var blob = _blobContainer.GetBlobClient(blobPath); + var blob = GetBlobContainer().GetBlobClient(blobPath); return blob; } @@ -420,7 +425,7 @@ private async Task GetBlobDirectoryReference(string path) prefix = NormalizePrefix(prefix); // Directory exists if path contains any files. - var page = _blobContainer.GetBlobsByHierarchyAsync(BlobTraits.Metadata, BlobStates.None, "/", prefix); + var page = GetBlobContainer().GetBlobsByHierarchyAsync(BlobTraits.Metadata, BlobStates.None, "/", prefix); var enumerator = page.GetAsyncEnumerator(); @@ -458,4 +463,32 @@ private static string NormalizePrefix(string prefix) return prefix; } } + + private BlobContainerClient GetBlobContainer() + { + if (_blobContainer is null) + { + if (!string.IsNullOrEmpty(_options.CredentialName)) + { + var credential = _optionsMonitor.Get(_options.CredentialName ?? AzureOptions.DefaultName).ToTokenCredential(); + + if (credential is null) + { + throw new InvalidOperationException($"Azure credential '{_options.CredentialName}' is not configured properly. Please check the authentication settings."); + } + + _blobContainer = new BlobContainerClient(_options.StorageAccountUri, credential); + } + else if (!string.IsNullOrEmpty(_options.ConnectionString)) + { + _blobContainer = new BlobContainerClient(_options.ConnectionString, _options.ContainerName); + } + else + { + throw new InvalidOperationException("BlobFileStore is not configured properly. Please check the ConnectionString or CredentialName settings."); + } + } + + return _blobContainer; + } } diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs index fd2d4f627fd..5a439935e6a 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs @@ -17,11 +17,34 @@ public abstract class BlobStorageOptions /// public string BasePath { get; set; } = ""; + /// + /// The name of the Azure credential configuration to use for authentication. + /// This should match a credential entry defined in your application's Azure settings. + /// + public string CredentialName { get; set; } + + /// + /// The URI of the Azure Storage account (e.g., "https://youraccount.blob.core.windows.net"). + /// This should point to the root of the storage account, not to a specific container. + /// If not provided, a connection string must be supplied, from which this URI will be derived automatically. + /// + public Uri StorageAccountUri { get; set; } + /// /// Returns a value indicating whether the basic state of the configuration is valid. /// public virtual bool IsConfigured() { - return !string.IsNullOrEmpty(ConnectionString) && !string.IsNullOrEmpty(ContainerName); + if (string.IsNullOrEmpty(ContainerName)) + { + return false; + } + + if (StorageAccountUri is not null) + { + return true; + } + + return !string.IsNullOrEmpty(ConnectionString); } } diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptionsConfiguration.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptionsConfiguration.cs index 5c56c27cd27..8d89591036c 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptionsConfiguration.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptionsConfiguration.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Environment.Shell; using OrchardCore.Liquid.Abstractions; @@ -33,6 +34,23 @@ public void Configure(TOptions options) var parser = new FluidOptionsParser(_sellSettings); options.ConnectionString = rawOptions.ConnectionString; + options.CredentialName = rawOptions.CredentialName; + + if (rawOptions.StorageAccountUri is null) + { + var accountName = ConnectionStringHelper.Extract(rawOptions.ConnectionString, "AccountName"); + + if (string.IsNullOrEmpty(accountName)) + { + throw new InvalidOperationException($"Unable to determine the storage account name for {typeof(TOptions).Name}."); + } + + rawOptions.StorageAccountUri = new Uri($"https://{accountName}.blob.core.windows.net"); + } + else + { + options.StorageAccountUri = rawOptions.StorageAccountUri; + } if (!string.IsNullOrEmpty(rawOptions.ContainerName)) { @@ -67,7 +85,7 @@ public void Configure(TOptions options) /// /// Allows you to configure additional options in an inherited class. /// - /// The options as returned by + /// The options as returned by . /// The options to configure. protected virtual void FurtherConfigure(TOptions rawOptions, TOptions options) { diff --git a/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileHandler.cs b/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileHandler.cs index 191119a8612..ab1ab1e88a0 100644 --- a/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileHandler.cs +++ b/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileHandler.cs @@ -1,9 +1,9 @@ +using OrchardCore.Catalogs; using OrchardCore.Indexing.Models; -using OrchardCore.Infrastructure.Entities; namespace OrchardCore.Indexing; -public interface IIndexProfileHandler : IModelHandler +public interface IIndexProfileHandler : ICatalogEntryHandler { /// /// Invoked when an has been synchronized. diff --git a/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileManager.cs b/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileManager.cs index 0757b7401bc..96686f1fbeb 100644 --- a/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileManager.cs +++ b/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileManager.cs @@ -1,7 +1,6 @@ using System.Text.Json.Nodes; -using OrchardCore.Abstractions.Indexing; +using OrchardCore.Catalogs.Models; using OrchardCore.Indexing.Models; -using OrchardCore.Infrastructure.Entities; namespace OrchardCore.Indexing; diff --git a/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileStore.cs b/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileStore.cs index e9ec4136592..67545e2e74d 100644 --- a/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileStore.cs +++ b/src/OrchardCore/OrchardCore.Indexing.Abstractions/IIndexProfileStore.cs @@ -1,4 +1,4 @@ -using OrchardCore.Abstractions.Indexing; +using OrchardCore.Catalogs.Models; using OrchardCore.Indexing.Models; namespace OrchardCore.Indexing; diff --git a/src/OrchardCore/OrchardCore.Indexing.Abstractions/PageResult.cs b/src/OrchardCore/OrchardCore.Indexing.Abstractions/PageResult.cs deleted file mode 100644 index 9932d4c43cc..00000000000 --- a/src/OrchardCore/OrchardCore.Indexing.Abstractions/PageResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OrchardCore.Abstractions.Indexing; - -public class PageResult -{ - public int Count { get; set; } - - public IEnumerable Models { get; set; } -} diff --git a/src/OrchardCore/OrchardCore.Indexing.Core/DefaultIndexProfileManager.cs b/src/OrchardCore/OrchardCore.Indexing.Core/DefaultIndexProfileManager.cs index db192e2283c..4e518ff6485 100644 --- a/src/OrchardCore/OrchardCore.Indexing.Core/DefaultIndexProfileManager.cs +++ b/src/OrchardCore/OrchardCore.Indexing.Core/DefaultIndexProfileManager.cs @@ -1,8 +1,7 @@ using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; -using OrchardCore.Abstractions.Indexing; +using OrchardCore.Catalogs.Models; using OrchardCore.Indexing.Models; -using OrchardCore.Infrastructure.Entities; using OrchardCore.Modules; namespace OrchardCore.Indexing.Core; @@ -130,9 +129,9 @@ public async ValueTask> PageAsync(int page, int { var result = await _store.PageAsync(page, pageSize, context); - foreach (var model in result.Models) + foreach (var entry in result.Entries) { - await LoadAsync(model); + await LoadAsync(entry); } return result; } diff --git a/src/OrchardCore/OrchardCore.Indexing.Core/DefaultIndexProfileStore.cs b/src/OrchardCore/OrchardCore.Indexing.Core/DefaultIndexProfileStore.cs index 08521586cd1..126e4de4265 100644 --- a/src/OrchardCore/OrchardCore.Indexing.Core/DefaultIndexProfileStore.cs +++ b/src/OrchardCore/OrchardCore.Indexing.Core/DefaultIndexProfileStore.cs @@ -1,4 +1,4 @@ -using OrchardCore.Abstractions.Indexing; +using OrchardCore.Catalogs.Models; using OrchardCore.Indexing.Core.Indexes; using OrchardCore.Indexing.Models; using YesSql; @@ -89,7 +89,7 @@ public async ValueTask> PageAsync(int page, int return new PageResult { Count = await records.CountAsync(), - Models = await records.Skip(skip).Take(pageSize).ListAsync(), + Entries = (await records.Skip(skip).Take(pageSize).ListAsync()).ToArray(), }; } diff --git a/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/ContentIndexProfileHandler.cs b/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/ContentIndexProfileHandler.cs index daccf2c72a6..e4db73fb47b 100644 --- a/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/ContentIndexProfileHandler.cs +++ b/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/ContentIndexProfileHandler.cs @@ -2,12 +2,12 @@ using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; +using OrchardCore.Catalogs.Models; using OrchardCore.ContentManagement; using OrchardCore.Entities; using OrchardCore.Environment.Shell.Scope; using OrchardCore.Indexing.Core.Models; using OrchardCore.Indexing.Models; -using OrchardCore.Infrastructure.Entities; namespace OrchardCore.Indexing.Core.Handlers; diff --git a/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/DefaultIndexProfileHandler.cs b/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/DefaultIndexProfileHandler.cs index 719132b76ec..5c1b6f69a39 100644 --- a/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/DefaultIndexProfileHandler.cs +++ b/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/DefaultIndexProfileHandler.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; +using OrchardCore.Catalogs.Models; using OrchardCore.Indexing.Models; using OrchardCore.Infrastructure.Entities; using OrchardCore.Modules; diff --git a/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/IndexProfileHandlerBase.cs b/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/IndexProfileHandlerBase.cs index 2603c65f50b..621eb88dbec 100644 --- a/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/IndexProfileHandlerBase.cs +++ b/src/OrchardCore/OrchardCore.Indexing.Core/Handlers/IndexProfileHandlerBase.cs @@ -1,9 +1,9 @@ +using OrchardCore.Catalogs; using OrchardCore.Indexing.Models; -using OrchardCore.Infrastructure.Entities; namespace OrchardCore.Indexing.Core.Handlers; -public abstract class IndexProfileHandlerBase : ModelHandlerBase, IIndexProfileHandler +public abstract class IndexProfileHandlerBase : CatalogEntryHandlerBase, IIndexProfileHandler { public virtual Task ExportingAsync(IndexProfileExportingContext context) => Task.CompletedTask; diff --git a/src/OrchardCore/OrchardCore.Indexing.Core/OrchardCore.Indexing.Core.csproj b/src/OrchardCore/OrchardCore.Indexing.Core/OrchardCore.Indexing.Core.csproj index 1108928a0a8..0aa259a4022 100644 --- a/src/OrchardCore/OrchardCore.Indexing.Core/OrchardCore.Indexing.Core.csproj +++ b/src/OrchardCore/OrchardCore.Indexing.Core/OrchardCore.Indexing.Core.csproj @@ -23,6 +23,7 @@ + diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/DeletedContext.cs b/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/DeletedContext.cs deleted file mode 100644 index 53b98f27072..00000000000 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/DeletedContext.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace OrchardCore.Infrastructure.Entities; - -public sealed class DeletedContext : HandlerContextBase -{ - public DeletedContext(T model) - : base(model) - { - } -} diff --git a/src/OrchardCore/OrchardCore.Redis.Abstractions/RedisOptions.cs b/src/OrchardCore/OrchardCore.Redis.Abstractions/RedisOptions.cs index 91fc93367e1..0ee1880a89d 100644 --- a/src/OrchardCore/OrchardCore.Redis.Abstractions/RedisOptions.cs +++ b/src/OrchardCore/OrchardCore.Redis.Abstractions/RedisOptions.cs @@ -18,7 +18,11 @@ public class RedisOptions public ConfigurationOptions ConfigurationOptions { get; set; } /// - /// Prefix alowing a Redis instance to be shared. + /// Prefix allowing a Redis instance to be shared. /// public string InstancePrefix { get; set; } + + public string ConnectionIdentifier { get; set; } + + public string CredentialName { get; set; } } diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchContentIndexProfileHandler.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchContentIndexProfileHandler.cs index 85eb14c6074..9f388f54c24 100644 --- a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchContentIndexProfileHandler.cs +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchContentIndexProfileHandler.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Localization; +using OrchardCore.Catalogs.Models; using OrchardCore.ContentManagement; using OrchardCore.Entities; using OrchardCore.Indexing.Core; diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchIndexHandler.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchIndexHandler.cs index 0ae556139f0..52ad6e59002 100644 --- a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchIndexHandler.cs +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchIndexHandler.cs @@ -1,10 +1,10 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Nodes; using Microsoft.Extensions.Localization; +using OrchardCore.Catalogs.Models; using OrchardCore.Entities; using OrchardCore.Indexing.Core.Handlers; using OrchardCore.Indexing.Models; -using OrchardCore.Infrastructure.Entities; using OrchardCore.Search.AzureAI.Models; namespace OrchardCore.Search.AzureAI.Handlers; diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchIndexProfileHandler.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchIndexProfileHandler.cs index 398b2cb8eb7..ed3919662b0 100644 --- a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchIndexProfileHandler.cs +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Handlers/AzureAISearchIndexProfileHandler.cs @@ -1,11 +1,11 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Nodes; using Microsoft.Extensions.Localization; +using OrchardCore.Catalogs.Models; using OrchardCore.ContentManagement; using OrchardCore.Entities; using OrchardCore.Indexing.Core.Handlers; using OrchardCore.Indexing.Models; -using OrchardCore.Infrastructure.Entities; using OrchardCore.Search.AzureAI.Models; namespace OrchardCore.Search.AzureAI.Handlers; diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAIAuthenticationType.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAIAuthenticationType.cs deleted file mode 100644 index 969b0baf1f9..00000000000 --- a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAIAuthenticationType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace OrchardCore.Search.AzureAI.Models; - -public enum AzureAIAuthenticationType -{ - Default = 0, - ApiKey = 1, - ManagedIdentity = 2, -} diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchDefaultOptions.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchDefaultOptions.cs index e0d44113131..bf373c51cee 100644 --- a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchDefaultOptions.cs +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchDefaultOptions.cs @@ -1,5 +1,6 @@ using Azure; using Azure.Search.Documents.Indexes.Models; +using OrchardCore.Azure.Core; using OrchardCore.Indexing; namespace OrchardCore.Search.AzureAI.Models; @@ -106,10 +107,15 @@ public class AzureAISearchDefaultOptions : ISearchProviderOptions public string Endpoint { get; set; } - public AzureAIAuthenticationType AuthenticationType { get; set; } + public AzureAuthenticationType AuthenticationType { get; set; } public bool DisableUIConfiguration { get; set; } + public string CredentialName { get; set; } + + public string ApiKey { get; set; } + + [Obsolete("This property is no longer used and will be removed in future releases. Instead set the ApiKey property in the OrchardCore_AzureAISearch settings.")] public AzureKeyCredential Credential { get; set; } // Environment prefix for all of the indexes. diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchDefaultSettings.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchDefaultSettings.cs index e71afc28666..4d3bfaa6d0a 100644 --- a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchDefaultSettings.cs +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Models/AzureAISearchDefaultSettings.cs @@ -1,10 +1,12 @@ +using OrchardCore.Azure.Core; + namespace OrchardCore.Search.AzureAI.Models; public class AzureAISearchDefaultSettings { public bool UseCustomConfiguration { get; set; } - public AzureAIAuthenticationType AuthenticationType { get; set; } + public AzureAuthenticationType AuthenticationType { get; set; } public string Endpoint { get; set; } diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAIClientFactory.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAIClientFactory.cs index 4cefecf53e5..0b146fc3210 100644 --- a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAIClientFactory.cs +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAIClientFactory.cs @@ -1,8 +1,10 @@ using System.Collections.Concurrent; +using Azure; using Azure.Identity; using Azure.Search.Documents; using Azure.Search.Documents.Indexes; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Search.AzureAI.Models; namespace OrchardCore.Search.AzureAI.Services; @@ -10,14 +12,18 @@ namespace OrchardCore.Search.AzureAI.Services; public class AzureAIClientFactory { private readonly AzureAISearchDefaultOptions _defaultOptions; + private readonly IOptionsMonitor _optionsMonitor; private SearchIndexClient _searchIndexClient; private ConcurrentDictionary _clients; - public AzureAIClientFactory(IOptions defaultOptions) + public AzureAIClientFactory( + IOptions defaultOptions, + IOptionsMonitor optionsMonitor) { _defaultOptions = defaultOptions.Value; + _optionsMonitor = optionsMonitor; } public SearchClient CreateSearchClient(string indexFullName) @@ -38,17 +44,24 @@ public SearchClient CreateSearchClient(string indexFullName) throw new Exception("The Endpoint provided to Azure AI Options contains invalid value."); } - if (_defaultOptions.AuthenticationType == AzureAIAuthenticationType.ApiKey && _defaultOptions.Credential != null) + if (_defaultOptions.AuthenticationType == AzureAuthenticationType.ApiKey) { - client = new SearchClient(endpoint, indexFullName, _defaultOptions.Credential); + client = new SearchClient(endpoint, indexFullName, new AzureKeyCredential(_defaultOptions.ApiKey)); } - else if (_defaultOptions.AuthenticationType == AzureAIAuthenticationType.ManagedIdentity) + else if (_defaultOptions.AuthenticationType == AzureAuthenticationType.ManagedIdentity) { client = new SearchClient(endpoint, indexFullName, GetManagedIdentityCredential()); } else { - client = new SearchClient(endpoint, indexFullName, new DefaultAzureCredential()); + var credentials = _optionsMonitor.Get(_defaultOptions.CredentialName ?? AzureOptions.DefaultName).ToTokenCredential(); + + client = new SearchClient(endpoint, indexFullName, credentials); + } + + if (client is null) + { + throw new NotSupportedException($"The Authentication Type '{_defaultOptions.AuthenticationType}' is not supported."); } _clients.TryAdd(indexFullName, client); @@ -71,11 +84,11 @@ public SearchIndexClient CreateSearchIndexClient() throw new Exception("The Endpoint provided to Azure AI Options contains invalid value."); } - if (_defaultOptions.AuthenticationType == AzureAIAuthenticationType.ApiKey && _defaultOptions.Credential != null) + if (_defaultOptions.AuthenticationType == AzureAuthenticationType.ApiKey) { - _searchIndexClient = new SearchIndexClient(endpoint, _defaultOptions.Credential); + _searchIndexClient = new SearchIndexClient(endpoint, new AzureKeyCredential(_defaultOptions.ApiKey)); } - else if (_defaultOptions.AuthenticationType == AzureAIAuthenticationType.ManagedIdentity) + else if (_defaultOptions.AuthenticationType == AzureAuthenticationType.ManagedIdentity) { _searchIndexClient = new SearchIndexClient(endpoint, GetManagedIdentityCredential()); } diff --git a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchDefaultOptionsConfigurations.cs b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchDefaultOptionsConfigurations.cs index b1adb9d01eb..d0d418ba4a7 100644 --- a/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchDefaultOptionsConfigurations.cs +++ b/src/OrchardCore/OrchardCore.Search.AzureAI.Core/Services/AzureAISearchDefaultOptionsConfigurations.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Environment.Shell.Configuration; using OrchardCore.Search.AzureAI.Models; using OrchardCore.Settings; @@ -71,11 +72,14 @@ private static void InitializeFromFileSettings(AzureAISearchDefaultOptions optio options.Endpoint = fileOptions.Endpoint; options.AuthenticationType = fileOptions.AuthenticationType; options.IdentityClientId = fileOptions.IdentityClientId; - - if (!string.IsNullOrWhiteSpace(fileOptions.Credential?.Key)) + options.ApiKey = fileOptions.ApiKey; + options.CredentialName = fileOptions.CredentialName; + if (!string.IsNullOrWhiteSpace(fileOptions.ApiKey)) { - options.AuthenticationType = AzureAIAuthenticationType.ApiKey; + options.AuthenticationType = AzureAuthenticationType.ApiKey; +#pragma warning disable CS0618 // Type or member is obsolete options.Credential = fileOptions.Credential; +#pragma warning restore CS0618 // Type or member is obsolete } } @@ -85,13 +89,19 @@ private void InitializeFromUISettings(AzureAISearchDefaultOptions options, Azure options.Endpoint = settings.Endpoint; options.AuthenticationType = settings.AuthenticationType; - if (settings.AuthenticationType == AzureAIAuthenticationType.ApiKey) + if (settings.AuthenticationType == AzureAuthenticationType.ApiKey) { var protector = _dataProtectionProvider.CreateProtector(ProtectorName); - options.Credential = new AzureKeyCredential(protector.Unprotect(settings.ApiKey)); + var unprotectedApiKey = protector.Unprotect(settings.ApiKey); + + options.ApiKey = unprotectedApiKey; + +#pragma warning disable CS0618 // Type or member is obsolete + options.Credential = new AzureKeyCredential(unprotectedApiKey); +#pragma warning restore CS0618 // Type or member is obsolete } - else if (settings.AuthenticationType == AzureAIAuthenticationType.ManagedIdentity) + else if (settings.AuthenticationType == AzureAuthenticationType.ManagedIdentity) { options.IdentityClientId = settings.IdentityClientId; } @@ -104,7 +114,6 @@ private static bool HasConnectionInfo(AzureAISearchDefaultOptions options) return false; } - return options.AuthenticationType != AzureAIAuthenticationType.ApiKey || - !string.IsNullOrEmpty(options.Credential?.Key); + return options.ConfigurationExists(); } } diff --git a/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Handlers/ElasticsearchContentIndexProfileHandler.cs b/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Handlers/ElasticsearchContentIndexProfileHandler.cs index 50289a8268f..61c3d191aaf 100644 --- a/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Handlers/ElasticsearchContentIndexProfileHandler.cs +++ b/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Handlers/ElasticsearchContentIndexProfileHandler.cs @@ -1,11 +1,11 @@ using Elastic.Clients.Elasticsearch.Mapping; +using OrchardCore.Catalogs.Models; using OrchardCore.ContentManagement; using OrchardCore.Contents.Indexing; using OrchardCore.Entities; using OrchardCore.Indexing.Core; using OrchardCore.Indexing.Core.Handlers; using OrchardCore.Indexing.Models; -using OrchardCore.Infrastructure.Entities; using OrchardCore.Search.Elasticsearch.Core.Mappings; using OrchardCore.Search.Elasticsearch.Core.Models; using OrchardCore.Search.Elasticsearch.Models; diff --git a/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Handlers/ElasticsearchIndexProfileHandler.cs b/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Handlers/ElasticsearchIndexProfileHandler.cs index f6900489635..61019a3b835 100644 --- a/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Handlers/ElasticsearchIndexProfileHandler.cs +++ b/src/OrchardCore/OrchardCore.Search.Elasticsearch.Core/Handlers/ElasticsearchIndexProfileHandler.cs @@ -4,11 +4,11 @@ using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.Mapping; using Microsoft.Extensions.Localization; +using OrchardCore.Catalogs.Models; using OrchardCore.ContentManagement; using OrchardCore.Entities; using OrchardCore.Indexing.Core.Handlers; using OrchardCore.Indexing.Models; -using OrchardCore.Infrastructure.Entities; using OrchardCore.Search.Elasticsearch.Core.Models; using OrchardCore.Search.Elasticsearch.Core.Services; using OrchardCore.Search.Elasticsearch.Models; diff --git a/src/OrchardCore/OrchardCore.Search.Lucene.Core/Handlers/LuceneContentIndexProfileHandler.cs b/src/OrchardCore/OrchardCore.Search.Lucene.Core/Handlers/LuceneContentIndexProfileHandler.cs index 32dfb34174b..31a2a1c626b 100644 --- a/src/OrchardCore/OrchardCore.Search.Lucene.Core/Handlers/LuceneContentIndexProfileHandler.cs +++ b/src/OrchardCore/OrchardCore.Search.Lucene.Core/Handlers/LuceneContentIndexProfileHandler.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; +using OrchardCore.Catalogs.Models; using OrchardCore.ContentManagement; using OrchardCore.Contents.Indexing; using OrchardCore.Entities; @@ -8,7 +9,6 @@ using OrchardCore.Indexing.Core.Handlers; using OrchardCore.Indexing.Core.Models; using OrchardCore.Indexing.Models; -using OrchardCore.Infrastructure.Entities; using OrchardCore.Modules; using OrchardCore.Search.Lucene.Model; using OrchardCore.Search.Lucene.Models; diff --git a/src/OrchardCore/OrchardCore.Search.Lucene.Core/Handlers/LuceneIndexProfileHandler.cs b/src/OrchardCore/OrchardCore.Search.Lucene.Core/Handlers/LuceneIndexProfileHandler.cs index 47bef0ba8b3..9e4b201cbf1 100644 --- a/src/OrchardCore/OrchardCore.Search.Lucene.Core/Handlers/LuceneIndexProfileHandler.cs +++ b/src/OrchardCore/OrchardCore.Search.Lucene.Core/Handlers/LuceneIndexProfileHandler.cs @@ -1,8 +1,8 @@ +using OrchardCore.Catalogs.Models; using OrchardCore.ContentManagement; using OrchardCore.Entities; using OrchardCore.Indexing.Core.Handlers; using OrchardCore.Indexing.Models; -using OrchardCore.Infrastructure.Entities; using OrchardCore.Search.Lucene.Models; namespace OrchardCore.Search.Lucene.Core.Handlers; diff --git a/src/OrchardCore/OrchardCore.Security.Core/Credential.cs b/src/OrchardCore/OrchardCore.Security.Core/Credential.cs new file mode 100644 index 00000000000..192842c866a --- /dev/null +++ b/src/OrchardCore/OrchardCore.Security.Core/Credential.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Nodes; +using OrchardCore.Catalogs; + +namespace OrchardCore.Security.Core; + +public class Credential : CatalogItem, INameAwareModel, ISourceAwareModel, IDisplayTextAwareModel, ICloneable +{ + public string DisplayText { get; set; } + + public string Name { get; set; } + + public string Source { get; set; } + + public DateTime CreatedUtc { get; set; } + + public string Author { get; set; } + + public string OwnerId { get; set; } + + public Credential Clone() + { + return new Credential + { + ItemId = ItemId, + Name = Name, + Source = Source, + CreatedUtc = CreatedUtc, + Author = Author, + OwnerId = OwnerId, + Properties = Properties?.Clone(), + }; + } +} diff --git a/src/OrchardCore/OrchardCore.Security.Core/CredentialHandler.cs b/src/OrchardCore/OrchardCore.Security.Core/CredentialHandler.cs new file mode 100644 index 00000000000..2f9e4284954 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Security.Core/CredentialHandler.cs @@ -0,0 +1,140 @@ +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using OrchardCore.Catalogs; +using OrchardCore.Catalogs.Models; +using OrchardCore.Modules; + +namespace OrchardCore.Security.Core; + +public sealed class CredentialHandler : CatalogEntryHandlerBase +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly SecurityOptions _securityOptions; + private readonly INamedCatalog _catalog; + private readonly IClock _clock; + + internal readonly IStringLocalizer S; + + public CredentialHandler( + IHttpContextAccessor httpContextAccessor, + IOptions securityOptions, + INamedCatalog catalog, + IClock clock, + IStringLocalizer stringLocalizer) + { + _httpContextAccessor = httpContextAccessor; + _securityOptions = securityOptions.Value; + _catalog = catalog; + _clock = clock; + S = stringLocalizer; + } + + public override Task InitializingAsync(InitializingContext context) + => PopulateAsync(context.Model, context.Data, true); + + public override Task UpdatingAsync(UpdatingContext context) + => PopulateAsync(context.Model, context.Data, false); + + public override async Task ValidatingAsync(ValidatingContext context) + { + if (string.IsNullOrWhiteSpace(context.Model.Name)) + { + context.Result.Fail(new ValidationResult(S["Credential Name is required."], [nameof(Credential.Name)])); + } + else + { + var connection = await _catalog.FindByNameAsync(context.Model.Name); + + if (connection is not null && connection.ItemId != context.Model.ItemId) + { + context.Result.Fail(new ValidationResult(S["A connection with this name already exists. The name must be unique."], [nameof(Credential.Name)])); + } + } + + if (string.IsNullOrWhiteSpace(context.Model.Source)) + { + context.Result.Fail(new ValidationResult(S["Source is required."], [nameof(Credential.Source)])); + } + else if (!_securityOptions.CredentialProviders.TryGetValue(context.Model.Source, out _)) + { + context.Result.Fail(new ValidationResult(S["Invalid source."], [nameof(Credential.Source)])); + } + } + + public override Task InitializedAsync(InitializedContext context) + { + context.Model.CreatedUtc = _clock.UtcNow; + + var user = _httpContextAccessor.HttpContext?.User; + + if (user != null) + { + context.Model.OwnerId = user.FindFirstValue(ClaimTypes.NameIdentifier); + context.Model.Author = user.Identity.Name; + } + + return Task.CompletedTask; + } + + private static Task PopulateAsync(Credential credential, JsonNode data, bool isNew) + { + if (isNew) + { + var name = data[nameof(Credential.Name)]?.GetValue()?.Trim(); + + if (!string.IsNullOrEmpty(name)) + { + credential.Name = name; + } + } + + var displayText = data[nameof(Credential.DisplayText)]?.GetValue()?.Trim(); + + if (!string.IsNullOrEmpty(displayText)) + { + credential.DisplayText = displayText; + } + + var source = data[nameof(Credential.Source)]?.GetValue()?.Trim(); + + if (!string.IsNullOrEmpty(source)) + { + credential.Source = source; + } + + var ownerId = data[nameof(Credential.OwnerId)]?.GetValue()?.Trim(); + + if (!string.IsNullOrEmpty(ownerId)) + { + credential.OwnerId = ownerId; + } + + var author = data[nameof(Credential.Author)]?.GetValue()?.Trim(); + + if (!string.IsNullOrEmpty(author)) + { + credential.Author = author; + } + + var createdUtc = data[nameof(Credential.CreatedUtc)]?.GetValue(); + + if (createdUtc.HasValue) + { + credential.CreatedUtc = createdUtc.Value; + } + + var properties = data[nameof(Credential.Properties)]?.AsObject(); + + if (properties != null) + { + credential.Properties ??= []; + credential.Properties.Merge(properties); + } + + return Task.CompletedTask; + } +} diff --git a/src/OrchardCore/OrchardCore.Security.Core/OrchardCore.Security.Core.csproj b/src/OrchardCore/OrchardCore.Security.Core/OrchardCore.Security.Core.csproj new file mode 100644 index 00000000000..8a7ffb9a5b8 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Security.Core/OrchardCore.Security.Core.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/OrchardCore/OrchardCore.Security.Core/SecurityConstants.cs b/src/OrchardCore/OrchardCore.Security.Core/SecurityConstants.cs new file mode 100644 index 00000000000..3303949013b --- /dev/null +++ b/src/OrchardCore/OrchardCore.Security.Core/SecurityConstants.cs @@ -0,0 +1,20 @@ +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Security.Core; + +public static class SecurityConstants +{ + public static class Features + { + public const string Area = "OrchardCore.Security"; + + public const string Credentials = "OrchardCore.Security.Credentials"; + } + + public static class Permissions + { + public static readonly Permission ManageSecurityHeadersSettings = new("ManageSecurityHeadersSettings", "Manage Security Headers Settings"); + + public static readonly Permission ManageCredentials = new Permission("ManageCredentials", "Manage Credentials"); + } +} diff --git a/src/OrchardCore/OrchardCore.Security.Core/SecurityOptions.cs b/src/OrchardCore/OrchardCore.Security.Core/SecurityOptions.cs new file mode 100644 index 00000000000..55c8e31d6cb --- /dev/null +++ b/src/OrchardCore/OrchardCore.Security.Core/SecurityOptions.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Localization; + +namespace OrchardCore.Security.Core; + +public sealed class SecurityOptions +{ + public Dictionary SecurityProviders { get; } = new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyDictionary CredentialProviders + => _credentialProviders; + + private readonly Dictionary _credentialProviders = new(StringComparer.OrdinalIgnoreCase); + + public void AddCredentialsProvider(string providerName, Action configure = null) + { + ArgumentException.ThrowIfNullOrEmpty(providerName); + + if (!_credentialProviders.TryGetValue(providerName, out var entry)) + { + entry = new CredentialOptionsEntry(providerName); + } + + if (configure != null) + { + configure(entry); + } + + if (string.IsNullOrEmpty(entry.DisplayName)) + { + entry.DisplayName = new LocalizedString(providerName, providerName); + } + + _credentialProviders[providerName] = entry; + } +} + +public sealed class CredentialOptionsEntry +{ + public CredentialOptionsEntry(string providerName) + { + ProviderName = providerName; + } + + public string ProviderName { get; } + + public LocalizedString DisplayName { get; set; } + + public LocalizedString Description { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Security.Core/SecurityOptionsConfiguration.cs b/src/OrchardCore/OrchardCore.Security.Core/SecurityOptionsConfiguration.cs new file mode 100644 index 00000000000..1d6fa54e566 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Security.Core/SecurityOptionsConfiguration.cs @@ -0,0 +1,148 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Environment.Shell.Configuration; + +namespace OrchardCore.Security.Core; + +public sealed class SecurityOptionsConfiguration : IConfigureOptions +{ + private readonly IShellConfiguration _shellConfiguration; + private readonly ILogger _logger; + + public SecurityOptionsConfiguration( + IShellConfiguration shellConfiguration, + ILogger logger) + { + _shellConfiguration = shellConfiguration; + _logger = logger; + } + + public void Configure(SecurityOptions options) + { + var providerSettings = _shellConfiguration.GetSection("OrchardCore:Security"); + + if (providerSettings is null) + { + _logger.LogWarning("The 'providers' in 'OrchardCore:Security' is not defined in the settings."); + + return; + } + + try + { + var providerSettingsElements = JsonSerializer.Deserialize(providerSettings.AsJsonNode()); + + var providerSettingsObject = JsonObject.Create(providerSettingsElements); + + if (providerSettingsObject is null) + { + _logger.LogWarning("The 'providers' in 'OrchardCore:Security' is invalid."); + + return; + } + + foreach (var providerPair in providerSettingsObject) + { + var providerName = providerPair.Key; + var providerNode = providerPair.Value; + + var credintialsNode = providerNode[nameof(SecurityProvider.Credintials)]; + + if (credintialsNode is null) + { + _logger.LogWarning("The provider with the name '{Name}' has no credentials. This provider will be ignore and not used.", providerName); + + continue; + } + + var collectionsElement = JsonSerializer.Deserialize(credintialsNode); + + var credentialsObject = JsonObject.Create(collectionsElement); + + if (credentialsObject is null || credentialsObject.Count == 0) + { + _logger.LogWarning("The provider with the name '{Name}' has no credentials. This provider will be ignore and not used.", providerName); + + continue; + } + + var cridentials = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var connectionPair in credentialsObject) + { + cridentials.Add(connectionPair.Key, connectionPair.Value.Deserialize()); + } + + if (cridentials.Count == 0) + { + _logger.LogWarning("The provider with the name '{Name}' has no valid credentials. This provider will be ignore and not used.", providerName); + + continue; + } + + var provider = new SecurityProvider() + { + Credintials = cridentials, + }; + + var defaultCredentialName = providerNode["DefaultCredentialName"]?.GetValue(); + + if (!string.IsNullOrEmpty(defaultCredentialName)) + { + provider.DefaultCredentialName = defaultCredentialName; + } + else + { + provider.DefaultCredentialName = cridentials.FirstOrDefault().Key; + } + + options.SecurityProviders.Add(providerName, provider); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Invalid 'CrestApps_AI:Providers' configuration. Please refer to the documentation for instructions on how to set it up correctly."); + } + } +} + +public sealed class SecurityProvider +{ + public string DefaultCredentialName { get; set; } + + public IDictionary Credintials { get; set; } +} + +[JsonConverter(typeof(SecurityCredentialConverter))] +public sealed class SecurityCredentialEntry : ReadOnlyDictionary +{ + public SecurityCredentialEntry(SecurityCredentialEntry connection) + : base(connection) + { + } + + public SecurityCredentialEntry(IDictionary dictionary) + : base(dictionary) + { + } +} + +public sealed class SecurityCredentialConverter : JsonConverter +{ + public override SecurityCredentialEntry Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Deserialize into a dictionary first. + var dictionary = JsonSerializer.Deserialize>(ref reader, options); + return dictionary != null ? new SecurityCredentialEntry(dictionary) : null; + } + + public override void Write(Utf8JsonWriter writer, SecurityCredentialEntry value, JsonSerializerOptions options) + { + // Serialize as dictionary. + JsonSerializer.Serialize(writer, (IDictionary)value, options); + } +} diff --git a/src/OrchardCore/OrchardCore.Shells.Azure/Configuration/BlobShellConfigurationSources.cs b/src/OrchardCore/OrchardCore.Shells.Azure/Configuration/BlobShellConfigurationSources.cs index 3497264f521..4edb94b91ef 100644 --- a/src/OrchardCore/OrchardCore.Shells.Azure/Configuration/BlobShellConfigurationSources.cs +++ b/src/OrchardCore/OrchardCore.Shells.Azure/Configuration/BlobShellConfigurationSources.cs @@ -58,9 +58,7 @@ public async Task SaveAsync(string tenant, IDictionary data) var fileInfo = await _shellsFileStore.GetFileInfoAsync(appsettings); -#pragma warning disable CA1859 // Use concrete types when possible for improved performance IDictionary configData; -#pragma warning restore CA1859 // Use concrete types when possible for improved performance if (fileInfo != null) { using var stream = await _shellsFileStore.GetFileStreamAsync(appsettings); diff --git a/src/OrchardCore/OrchardCore.Shells.Azure/Extensions/BlobShellsOrchardCoreBuilderExtensions.cs b/src/OrchardCore/OrchardCore.Shells.Azure/Extensions/BlobShellsOrchardCoreBuilderExtensions.cs index eb5d280ec83..8ecc9199bb8 100644 --- a/src/OrchardCore/OrchardCore.Shells.Azure/Extensions/BlobShellsOrchardCoreBuilderExtensions.cs +++ b/src/OrchardCore/OrchardCore.Shells.Azure/Extensions/BlobShellsOrchardCoreBuilderExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using OrchardCore.Azure.Core; using OrchardCore.Environment.Shell; using OrchardCore.Environment.Shell.Configuration; using OrchardCore.FileStorage.AzureBlob; @@ -34,7 +35,9 @@ public static OrchardCoreBuilder AddAzureShellsConfiguration(this OrchardCoreBui var clock = sp.GetRequiredService(); var contentTypeProvider = sp.GetRequiredService(); - var fileStore = new BlobFileStore(blobOptions, clock, contentTypeProvider); + var optionsMonitor = sp.GetRequiredService>(); + + var fileStore = new BlobFileStore(blobOptions, clock, optionsMonitor, contentTypeProvider); return new BlobShellsFileStore(fileStore); }); diff --git a/src/OrchardCore/OrchardCore/Catalogs/Catalog.cs b/src/OrchardCore/OrchardCore/Catalogs/Catalog.cs new file mode 100644 index 00000000000..86e1c3c54a2 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/Catalog.cs @@ -0,0 +1,179 @@ +using OrchardCore.Catalogs.Models; +using OrchardCore.Documents; + +namespace OrchardCore.Catalogs; + +public class Catalog : ICatalog + where T : CatalogItem +{ + protected readonly IDocumentManager> DocumentManager; + + public Catalog(IDocumentManager> documentManager) + { + DocumentManager = documentManager; + } + + public async ValueTask DeleteAsync(T entry) + { + ArgumentNullException.ThrowIfNull(entry); + + var document = await DocumentManager.GetOrCreateMutableAsync(); + + if (!document.Records.TryGetValue(entry.ItemId, out var existingInstance)) + { + return false; + } + + Deleting(entry, document); + + var removed = document.Records.Remove(entry.ItemId); + + if (removed) + { + await DocumentManager.UpdateAsync(document); + } + + return removed; + } + + public async ValueTask FindByIdAsync(string id) + { + ArgumentException.ThrowIfNullOrEmpty(id); + + var document = await DocumentManager.GetOrCreateImmutableAsync(); + + if (document.Records.TryGetValue(id, out var record)) + { + return Clone(record); + } + + return null; + } + + public async ValueTask> GetAsync(IEnumerable ids) + { + ArgumentNullException.ThrowIfNull(ids); + + var document = await DocumentManager.GetOrCreateImmutableAsync(); + + return ids.Where(document.Records.ContainsKey) + .Select(id => Clone(document.Records[id])) + .ToArray(); + } + + public async ValueTask> PageAsync(int page, int pageSize, TQuery context) + where TQuery : QueryContext + { + var records = await LocateInstancesAsync(context); + + var skip = (page - 1) * pageSize; + + return new PageResult + { + Count = records.Count(), + Entries = records.Skip(skip).Take(pageSize).Select(Clone).ToArray(), + }; + } + + public async ValueTask> GetAllAsync() + { + var document = await DocumentManager.GetOrCreateImmutableAsync(); + + return document.Records.Values.Select(Clone).ToArray(); + } + + public async ValueTask CreateAsync(T record) + { + ArgumentNullException.ThrowIfNull(record); + + var document = await DocumentManager.GetOrCreateMutableAsync(); + + if (string.IsNullOrEmpty(record.ItemId)) + { + record.ItemId = IdGenerator.GenerateId(); + } + + Saving(record, document); + + document.Records[record.ItemId] = record; + + await DocumentManager.UpdateAsync(document); + } + + public async ValueTask UpdateAsync(T record) + { + ArgumentNullException.ThrowIfNull(record); + + var document = await DocumentManager.GetOrCreateMutableAsync(); + + if (string.IsNullOrEmpty(record.ItemId)) + { + record.ItemId = IdGenerator.GenerateId(); + } + + Saving(record, document); + + document.Records[record.ItemId] = record; + + await DocumentManager.UpdateAsync(document); + } + + public ValueTask SaveChangesAsync() + { + return ValueTask.CompletedTask; + } + + protected virtual void Deleting(T model, DictionaryDocument document) + { + } + + protected virtual async ValueTask> LocateInstancesAsync(QueryContext context) + { + var document = await DocumentManager.GetOrCreateImmutableAsync(); + + if (context == null) + { + return document.Records.Values; + } + + var records = GetSortable(context, document.Records.Values.AsEnumerable()); + + return records; + } + + protected virtual IEnumerable GetSortable(QueryContext context, IEnumerable records) + { + if (!string.IsNullOrEmpty(context.Name)) + { + records = records.Where(x => (x is INameAwareModel named && named.Name.Contains(context.Name, StringComparison.OrdinalIgnoreCase)) || + (x is IDisplayTextAwareModel displayModel && displayModel.DisplayText.Contains(context.Name, StringComparison.OrdinalIgnoreCase)) || + (x is not INameAwareModel && x is not IDisplayTextAwareModel)); + } + + if (context.Sorted) + { + records = records.OrderBy(x => x is IDisplayTextAwareModel displayModel ? displayModel.DisplayText : x is INameAwareModel named ? named.Name : string.Empty); + } + + return records; + } + + protected virtual void Saving(T record, DictionaryDocument document) + { + } + + protected static T Clone(T record) + { + if (record is ICloneable cloneableOfT) + { + return cloneableOfT.Clone(); + } + + if (record is ICloneable clonable) + { + return (T)clonable.Clone(); + } + + return record; + } +} diff --git a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ModelHandlerBase.cs b/src/OrchardCore/OrchardCore/Catalogs/CatalogEntryHandlerBase.cs similarity index 88% rename from src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ModelHandlerBase.cs rename to src/OrchardCore/OrchardCore/Catalogs/CatalogEntryHandlerBase.cs index 25868007dbc..b6c0e38d575 100644 --- a/src/OrchardCore/OrchardCore.Infrastructure.Abstractions/Entities/ModelHandlerBase.cs +++ b/src/OrchardCore/OrchardCore/Catalogs/CatalogEntryHandlerBase.cs @@ -1,6 +1,8 @@ -namespace OrchardCore.Infrastructure.Entities; +using OrchardCore.Catalogs.Models; -public abstract class ModelHandlerBase : IModelHandler +namespace OrchardCore.Catalogs; + +public abstract class CatalogEntryHandlerBase : ICatalogEntryHandler { public virtual Task DeletedAsync(DeletedContext context) => Task.CompletedTask; diff --git a/src/OrchardCore/OrchardCore/Catalogs/CatalogItem.cs b/src/OrchardCore/OrchardCore/Catalogs/CatalogItem.cs new file mode 100644 index 00000000000..cac4476ee60 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/CatalogItem.cs @@ -0,0 +1,11 @@ +using OrchardCore.Entities; + +namespace OrchardCore.Catalogs; + +public class CatalogItem : Entity +{ + /// + /// Gets or sets the unique identifier for the catalog item. + /// + public string ItemId { get; set; } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/CatalogManager.cs b/src/OrchardCore/OrchardCore/Catalogs/CatalogManager.cs new file mode 100644 index 00000000000..bbb60591350 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/CatalogManager.cs @@ -0,0 +1,171 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using OrchardCore.Catalogs.Models; +using OrchardCore.Modules; + +namespace OrchardCore.Catalogs; + +public class CatalogManager : ICatalogManager + where T : CatalogItem, new() +{ + protected readonly ICatalog Catalog; + protected readonly ILogger Logger; + protected readonly IEnumerable> Handlers; + + public CatalogManager( + ICatalog catalog, + IEnumerable> handlers, + ILogger> logger) + { + Catalog = catalog; + Handlers = handlers; + Logger = logger; + } + + protected CatalogManager( + ICatalog store, + IEnumerable> handlers, + ILogger logger) + { + Catalog = store; + Handlers = handlers.Reverse(); + Logger = logger; + } + + public async ValueTask DeleteAsync(T entry) + { + ArgumentNullException.ThrowIfNull(entry); + + var deletingContext = new DeletingContext(entry); + await Handlers.InvokeAsync((handler, ctx) => handler.DeletingAsync(ctx), deletingContext, Logger); + + if (string.IsNullOrEmpty(entry.ItemId)) + { + return false; + } + + var removed = await Catalog.DeleteAsync(entry); + + await DeletedAsync(entry); + + var deletedContext = new DeletedContext(entry); + await Handlers.InvokeAsync((handler, ctx) => handler.DeletedAsync(ctx), deletedContext, Logger); + + return removed; + } + + public async ValueTask FindByIdAsync(string id) + { + var entry = await Catalog.FindByIdAsync(id); + + if (entry is not null) + { + await LoadAsync(entry); + + return entry; + } + + return null; + } + + public virtual async ValueTask NewAsync(JsonNode data = null) + { + var id = IdGenerator.GenerateId(); + + var entry = new T() + { + ItemId = id, + }; + + var initializingContext = new InitializingContext(entry, data); + await Handlers.InvokeAsync((handler, ctx) => handler.InitializingAsync(ctx), initializingContext, Logger); + + var initializedContext = new InitializedContext(entry); + await Handlers.InvokeAsync((handler, ctx) => handler.InitializedAsync(ctx), initializedContext, Logger); + + if (string.IsNullOrEmpty(entry.ItemId)) + { + entry.ItemId = id; + } + + return entry; + } + + public async ValueTask> PageAsync(int page, int pageSize, TQuery context) + where TQuery : QueryContext + { + var result = await Catalog.PageAsync(page, pageSize, context); + + foreach (var entry in result.Entries) + { + await LoadAsync(entry); + } + + return result; + } + + public async ValueTask CreateAsync(T entry) + { + ArgumentNullException.ThrowIfNull(entry); + + var creatingContext = new CreatingContext(entry); + await Handlers.InvokeAsync((handler, ctx) => handler.CreatingAsync(ctx), creatingContext, Logger); + + await Catalog.CreateAsync(entry); + await Catalog.SaveChangesAsync(); + + var createdContext = new CreatedContext(entry); + await Handlers.InvokeAsync((handler, ctx) => handler.CreatedAsync(ctx), createdContext, Logger); + } + + public async ValueTask UpdateAsync(T entry, JsonNode data = null) + { + ArgumentNullException.ThrowIfNull(entry); + + var updatingContext = new UpdatingContext(entry, data); + await Handlers.InvokeAsync((handler, ctx) => handler.UpdatingAsync(ctx), updatingContext, Logger); + + await Catalog.UpdateAsync(entry); + await Catalog.SaveChangesAsync(); + + var updatedContext = new UpdatedContext(entry); + await Handlers.InvokeAsync((handler, ctx) => handler.UpdatedAsync(ctx), updatedContext, Logger); + } + + public async ValueTask ValidateAsync(T entry) + { + ArgumentNullException.ThrowIfNull(entry); + + var validatingContext = new ValidatingContext(entry); + await Handlers.InvokeAsync((handler, ctx) => handler.ValidatingAsync(ctx), validatingContext, Logger); + + var validatedContext = new ValidatedContext(entry, validatingContext.Result); + await Handlers.InvokeAsync((handler, ctx) => handler.ValidatedAsync(ctx), validatedContext, Logger); + + return validatingContext.Result; + } + + public async ValueTask> GetAllAsync() + { + var models = await Catalog.GetAllAsync(); + + foreach (var model in models) + { + await LoadAsync(model); + } + + return models; + } + + protected virtual ValueTask DeletedAsync(T entry) + { + return ValueTask.CompletedTask; + } + + protected virtual async Task LoadAsync(T entry) + { + var loadedContext = new LoadedContext(entry); + + await Handlers.InvokeAsync((handler, context) => handler.LoadedAsync(context), loadedContext, Logger); + } +} diff --git a/src/OrchardCore/OrchardCore.Indexing.Core/DictionaryDocument.cs b/src/OrchardCore/OrchardCore/Catalogs/DictionaryDocument.cs similarity index 80% rename from src/OrchardCore/OrchardCore.Indexing.Core/DictionaryDocument.cs rename to src/OrchardCore/OrchardCore/Catalogs/DictionaryDocument.cs index bfc6bb9fba2..ba8c0f78e5d 100644 --- a/src/OrchardCore/OrchardCore.Indexing.Core/DictionaryDocument.cs +++ b/src/OrchardCore/OrchardCore/Catalogs/DictionaryDocument.cs @@ -1,6 +1,6 @@ using OrchardCore.Data.Documents; -namespace OrchardCore.Indexing.Core; +namespace OrchardCore.Catalogs; public sealed class DictionaryDocument : Document { diff --git a/src/OrchardCore/OrchardCore/Catalogs/Models/CatalogEntryAction.cs b/src/OrchardCore/OrchardCore/Catalogs/Models/CatalogEntryAction.cs new file mode 100644 index 00000000000..842d28f6b55 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/Models/CatalogEntryAction.cs @@ -0,0 +1,7 @@ +namespace OrchardCore.Catalogs.Models; + +public enum CatalogEntryAction +{ + None, + Remove, +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/Models/CatalogEntryOptions.cs b/src/OrchardCore/OrchardCore/Catalogs/Models/CatalogEntryOptions.cs new file mode 100644 index 00000000000..2e3c2cf74c5 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/Models/CatalogEntryOptions.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace OrchardCore.Catalogs.Models; + +public class CatalogEntryOptions +{ + public string Search { get; set; } + + public TOptions BulkAction { get; set; } + + [BindNever] + public IList BulkActions { get; set; } +} + +public class CatalogEntryOptions : CatalogEntryOptions; diff --git a/src/OrchardCore/OrchardCore/Catalogs/Models/CatalogEntryViewModel.cs b/src/OrchardCore/OrchardCore/Catalogs/Models/CatalogEntryViewModel.cs new file mode 100644 index 00000000000..791d19fc898 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/Models/CatalogEntryViewModel.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Catalogs.Models; + +public class CatalogEntryViewModel +{ + public T Model { get; set; } + + public dynamic Shape { get; set; } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/Models/EditCatalogEntryViewModel.cs b/src/OrchardCore/OrchardCore/Catalogs/Models/EditCatalogEntryViewModel.cs new file mode 100644 index 00000000000..a9447de4c1a --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/Models/EditCatalogEntryViewModel.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Catalogs.Models; + +public class EditCatalogEntryViewModel +{ + public string DisplayName { get; set; } + + public dynamic Editor { get; set; } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/Models/ListCatalogEntryViewModel.cs b/src/OrchardCore/OrchardCore/Catalogs/Models/ListCatalogEntryViewModel.cs new file mode 100644 index 00000000000..d4c2da24662 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/Models/ListCatalogEntryViewModel.cs @@ -0,0 +1,13 @@ +namespace OrchardCore.Catalogs.Models; + +public class ListCatalogEntryViewModel +{ + public CatalogEntryOptions Options { get; set; } + + public dynamic Pager { get; set; } +} + +public class ListCatalogEntryViewModel : ListCatalogEntryViewModel +{ + public IList Models { get; set; } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/Models/ListSourceCatalogEntryViewModel.cs b/src/OrchardCore/OrchardCore/Catalogs/Models/ListSourceCatalogEntryViewModel.cs new file mode 100644 index 00000000000..571b6f3489e --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/Models/ListSourceCatalogEntryViewModel.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Catalogs.Models; + +public class ListSourceCatalogEntryViewModel : ListSourceModelViewModel +{ + public IList> Models { get; set; } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/Models/ListSourceModelEntryViewModel.cs b/src/OrchardCore/OrchardCore/Catalogs/Models/ListSourceModelEntryViewModel.cs new file mode 100644 index 00000000000..35258921056 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/Models/ListSourceModelEntryViewModel.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Catalogs.Models; + +public class ListSourceModelEntryViewModel : ListSourceModelViewModel +{ + public IList> Models { get; set; } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/Models/ListSourceModelViewModel.cs b/src/OrchardCore/OrchardCore/Catalogs/Models/ListSourceModelViewModel.cs new file mode 100644 index 00000000000..8da08b4b6c6 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/Models/ListSourceModelViewModel.cs @@ -0,0 +1,16 @@ +namespace OrchardCore.Catalogs.Models; + +public class ListSourceModelViewModel : ListCatalogEntryViewModel +{ + public IEnumerable Sources { get; set; } +} + +public class ListSourceModelViewModel : ListCatalogEntryViewModel +{ + public IEnumerable Sources { get; set; } +} + +public class ListSourceModelViewModel : ListCatalogEntryViewModel +{ + public IEnumerable Sources { get; set; } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/NamedCatalog.cs b/src/OrchardCore/OrchardCore/Catalogs/NamedCatalog.cs new file mode 100644 index 00000000000..84d77a84c8d --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/NamedCatalog.cs @@ -0,0 +1,36 @@ +using OrchardCore.Documents; + +namespace OrchardCore.Catalogs; + +public class NamedCatalog : Catalog, INamedCatalog + where T : CatalogItem, INameAwareModel +{ + public NamedCatalog(IDocumentManager> documentManager) + : base(documentManager) + { + } + + public async ValueTask FindByNameAsync(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + var document = await DocumentManager.GetOrCreateImmutableAsync(); + + var record = document.Records.Values.FirstOrDefault(x => OrdinalIgnoreCaseEquals(x.Name, name)); + + return Clone(record); + } + + protected override void Saving(T record, DictionaryDocument document) + { + if (document.Records.Values.Any(x => OrdinalIgnoreCaseEquals(x.Name, record.Name) && x.ItemId != record.ItemId)) + { + throw new InvalidOperationException("There is already another model with the same name."); + } + } + + protected static bool OrdinalIgnoreCaseEquals(string str1, string str2) + { + return str1.Equals(str2, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/NamedCatalogManager.cs b/src/OrchardCore/OrchardCore/Catalogs/NamedCatalogManager.cs new file mode 100644 index 00000000000..8a310aa7499 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/NamedCatalogManager.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Logging; + +namespace OrchardCore.Catalogs; + +public class NamedCatalogManager : CatalogManager, INamedCatalogManager + where T : CatalogItem, INameAwareModel, new() +{ + protected readonly INamedCatalog NamedCatalog; + + public NamedCatalogManager( + INamedCatalog catalog, + IEnumerable> handlers, + ILogger> logger) + : base(catalog, handlers, logger) + { + NamedCatalog = catalog; + } + + protected NamedCatalogManager( + INamedCatalog catalog, + IEnumerable> handlers, + ILogger logger) + : base(catalog, handlers, logger) + { + NamedCatalog = catalog; + } + + public async ValueTask FindByNameAsync(string name) + { + var entry = await NamedCatalog.FindByNameAsync(name); + + if (entry is not null) + { + await LoadAsync(entry); + } + + return entry; + } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/NamedSourceCatalog.cs b/src/OrchardCore/OrchardCore/Catalogs/NamedSourceCatalog.cs new file mode 100644 index 00000000000..fa6ea1582d5 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/NamedSourceCatalog.cs @@ -0,0 +1,48 @@ +using OrchardCore.Documents; + +namespace OrchardCore.Catalogs; + +public class NamedSourceCatalog : SourceCatalog, INamedSourceCatalog, ISourceCatalog + where T : CatalogItem, INameAwareModel, ISourceAwareModel +{ + public NamedSourceCatalog(IDocumentManager> documentManager) + : base(documentManager) + { + } + + public async ValueTask FindByNameAsync(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + var document = await DocumentManager.GetOrCreateImmutableAsync(); + + var record = document.Records.Values.FirstOrDefault(x => OrdinalIgnoreCaseEquals(x.Name, name)); + + return Clone(record); + } + + public async ValueTask GetAsync(string name, string source) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentException.ThrowIfNullOrEmpty(source); + + var document = await DocumentManager.GetOrCreateImmutableAsync(); + + var record = document.Records.Values.FirstOrDefault(x => OrdinalIgnoreCaseEquals(x.Name, name) && OrdinalIgnoreCaseEquals(x.Source, source)); + + return Clone(record); + } + + protected static bool OrdinalIgnoreCaseEquals(string str1, string str2) + { + return str1.Equals(str2, StringComparison.OrdinalIgnoreCase); + } + + protected override void Saving(T record, DictionaryDocument document) + { + if (document.Records.Values.Any(x => OrdinalIgnoreCaseEquals(x.Name, record.Name) && x.ItemId != record.ItemId)) + { + throw new InvalidOperationException("There is already another model with the same name."); + } + } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/NamedSourceCatalogManager.cs b/src/OrchardCore/OrchardCore/Catalogs/NamedSourceCatalogManager.cs new file mode 100644 index 00000000000..d725662afca --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/NamedSourceCatalogManager.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Logging; + +namespace OrchardCore.Catalogs; + +public class NamedSourceCatalogManager : SourceCatalogManager, INamedCatalogManager, ISourceCatalogManager, INamedSourceCatalogManager + where T : CatalogItem, INameAwareModel, ISourceAwareModel, new() +{ + protected readonly INamedSourceCatalog NamedSourceModelStore; + + public NamedSourceCatalogManager( + INamedSourceCatalog store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + NamedSourceModelStore = store; + } + + public async ValueTask FindByNameAsync(string name) + { + var entry = await NamedSourceModelStore.FindByNameAsync(name); + + if (entry is not null) + { + await LoadAsync(entry); + } + + return entry; + } + + public async ValueTask GetAsync(string name, string source) + { + var entry = await NamedSourceModelStore.GetAsync(name, source); + + if (entry is not null) + { + await LoadAsync(entry); + } + + return entry; + } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/ServiceCollectionExtensions.cs b/src/OrchardCore/OrchardCore/Catalogs/ServiceCollectionExtensions.cs new file mode 100644 index 00000000000..813a90f10ae --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/ServiceCollectionExtensions.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace OrchardCore.Catalogs; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddCatalogManagers(this IServiceCollection services) + { + services.TryAddScoped(typeof(ICatalogManager<>), typeof(CatalogManager<>)); + services.TryAddScoped(typeof(INamedCatalogManager<>), typeof(NamedCatalogManager<>)); + services.TryAddScoped(typeof(ISourceCatalogManager<>), typeof(SourceCatalogManager<>)); + services.TryAddScoped(typeof(INamedSourceCatalogManager<>), typeof(NamedSourceCatalogManager<>)); + + return services; + } + + public static IServiceCollection AddCatalogs(this IServiceCollection services) + { + services.TryAddScoped(typeof(ICatalog<>), typeof(Catalog<>)); + services.TryAddScoped(typeof(INamedCatalog<>), typeof(NamedCatalog<>)); + services.TryAddScoped(typeof(ISourceCatalog<>), typeof(SourceCatalog<>)); + services.TryAddScoped(typeof(INamedSourceCatalog<>), typeof(NamedSourceCatalog<>)); + + return services; + } + + public static IServiceCollection AddCatalog(this IServiceCollection services) + where TModel : CatalogItem + { + services.AddScoped, Catalog>(); + + return services; + } + + public static IServiceCollection AddNamedCatalog(this IServiceCollection services) + where TModel : CatalogItem, INameAwareModel + { + services.AddScoped, NamedCatalog>(); + + return services; + } + + public static IServiceCollection AddSourceCatalog(this IServiceCollection services) + where TModel : CatalogItem, ISourceAwareModel + { + services.AddScoped, SourceCatalog>(); + + return services; + } + + public static IServiceCollection AddNamedSourceCatalog(this IServiceCollection services) + where TModel : CatalogItem, INameAwareModel, ISourceAwareModel + { + services.AddScoped, NamedSourceCatalog>(); + + return services; + } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/SourceCatalog.cs b/src/OrchardCore/OrchardCore/Catalogs/SourceCatalog.cs new file mode 100644 index 00000000000..d06a141263a --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/SourceCatalog.cs @@ -0,0 +1,37 @@ +using OrchardCore.Catalogs.Models; +using OrchardCore.Documents; + +namespace OrchardCore.Catalogs; + +public class SourceCatalog : Catalog, ISourceCatalog + where T : CatalogItem, ISourceAwareModel +{ + private readonly IDocumentManager> _documentManager; + + public SourceCatalog(IDocumentManager> documentManager) + : base(documentManager) + { + _documentManager = documentManager; + } + + public async ValueTask> GetAsync(string source) + { + ArgumentException.ThrowIfNullOrEmpty(source); + + var document = await _documentManager.GetOrCreateImmutableAsync(); + + return document.Records.Values.Where(x => x.Source.Equals(source, StringComparison.OrdinalIgnoreCase)) + .Select(Clone) + .ToArray(); + } + + protected override IEnumerable GetSortable(QueryContext context, IEnumerable records) + { + if (!string.IsNullOrEmpty(context.Source)) + { + records = records.Where(x => x.Source.Equals(context.Source, StringComparison.OrdinalIgnoreCase)); + } + + return base.GetSortable(context, records); + } +} diff --git a/src/OrchardCore/OrchardCore/Catalogs/SourceCatalogManager.cs b/src/OrchardCore/OrchardCore/Catalogs/SourceCatalogManager.cs new file mode 100644 index 00000000000..470b23947c9 --- /dev/null +++ b/src/OrchardCore/OrchardCore/Catalogs/SourceCatalogManager.cs @@ -0,0 +1,87 @@ +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using OrchardCore.Catalogs.Models; +using OrchardCore.Modules; + +namespace OrchardCore.Catalogs; + +public class SourceCatalogManager : CatalogManager, ISourceCatalogManager + where T : CatalogItem, ISourceAwareModel, new() +{ + protected readonly ISourceCatalog SourceCatalog; + + public SourceCatalogManager( + ISourceCatalog sourceCatalog, + IEnumerable> handlers, + ILogger> logger) + : base(sourceCatalog, handlers, logger) + { + SourceCatalog = sourceCatalog; + } + + public async ValueTask> FindBySourceAsync(string source) + { + ArgumentException.ThrowIfNullOrEmpty(source); + + var entries = (await Catalog.GetAllAsync()).Where(x => x.Source == source).ToArray(); + + foreach (var entry in entries) + { + await LoadAsync(entry); + } + + return entries; + } + + public async ValueTask> GetAsync(string source) + { + var entries = await SourceCatalog.GetAsync(source); + + foreach (var entry in entries) + { + await LoadAsync(entry); + } + + return entries; + } + + public async ValueTask NewAsync(string source, JsonNode data = null) + { + ArgumentException.ThrowIfNullOrEmpty(source); + + var id = IdGenerator.GenerateId(); + + var entry = new T() + { + ItemId = id, + Source = source, + }; + + var initializingContext = new InitializingContext(entry, data); + await Handlers.InvokeAsync((handler, ctx) => handler.InitializingAsync(ctx), initializingContext, Logger); + + var initializedContext = new InitializedContext(entry); + await Handlers.InvokeAsync((handler, ctx) => handler.InitializedAsync(ctx), initializedContext, Logger); + + if (string.IsNullOrEmpty(entry.ItemId)) + { + entry.ItemId = id; + } + + entry.Source = source; + + return entry; + } + + public override ValueTask NewAsync(JsonNode data = null) + { + var source = data?["Source"]?.GetValue(); + + if (string.IsNullOrEmpty(source)) + { + throw new InvalidOperationException("Data must contain a Source entry"); + } + + return NewAsync(source, data); + } +} diff --git a/src/OrchardCore/OrchardCore/Modules/Extensions/ServiceCollectionExtensions.cs b/src/OrchardCore/OrchardCore/Modules/Extensions/ServiceCollectionExtensions.cs index b272d2dbd92..8ce2302d034 100644 --- a/src/OrchardCore/OrchardCore/Modules/Extensions/ServiceCollectionExtensions.cs +++ b/src/OrchardCore/OrchardCore/Modules/Extensions/ServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using OrchardCore; +using OrchardCore.Catalogs; using OrchardCore.Environment.Extensions; using OrchardCore.Environment.Shell; using OrchardCore.Environment.Shell.Builders; @@ -102,6 +103,7 @@ public static OrchardCoreBuilder AddOrchardCore(this IServiceCollection services AddSameSiteCookieBackwardsCompatibility(builder); AddAuthentication(builder); AddDataProtection(builder); + AddCatalogServices(builder); // Register the list of services to be resolved later on services.AddSingleton(services); @@ -123,6 +125,14 @@ public static IServiceCollection AddOrchardCore(this IServiceCollection services return services; } + private static void AddCatalogServices(OrchardCoreBuilder builder) + { + var services = builder.ApplicationServices; + + services.AddCatalogs(); + services.AddCatalogManagers(); + } + private static void AddDefaultServices(OrchardCoreBuilder builder) { var services = builder.ApplicationServices; diff --git a/src/docs/reference/README.md b/src/docs/reference/README.md index 7bb49e7db55..b4c5e20932a 100644 --- a/src/docs/reference/README.md +++ b/src/docs/reference/README.md @@ -43,6 +43,7 @@ Here's a categorized overview of all built-in Orchard Core features at a glance. - [Ticket store](modules/Users/TicketStore.md) - [OpenId](modules/OpenId/README.md) - [Roles](modules/Roles/README.md) +- [Azure](modules/Azure/README.md) ### Content @@ -121,6 +122,7 @@ Here's a categorized overview of all built-in Orchard Core features at a glance. - [SMTP Provider](modules/Email.Smtp/README.md) - [Azure Email Provider](modules/Email.Azure/README.md) - [Redis](modules/Redis/README.md) +- [Redis Azure](modules/Redis.Azure/README.md) - [Deployment](modules/Deployment/README.md) - [Diagnostics](modules/Diagnostics/README.md) - [Remote Deployment](modules/Deployment.Remote/README.md) diff --git a/src/docs/reference/modules/Azure/README.md b/src/docs/reference/modules/Azure/README.md new file mode 100644 index 00000000000..169947a1397 --- /dev/null +++ b/src/docs/reference/modules/Azure/README.md @@ -0,0 +1,92 @@ +# Azure Module (`OrchardCore.Azure`) + +The **Azure Module** provides a centralized way to manage all Azure connections and credentials within Orchard Core. +Other modules can reference these credentials **by name**, allowing for consistent authentication and clean configuration management across tenants or services. + +!!! note + The credential named `Default` is a special case: + - It is automatically used whenever a service does not explicitly specify a credential name. + - It is also returned if you resolve `IOptions` without a specific key. + +--- + +## Configuration + +You can define Azure credentials in a settings provider such as `appsettings.json`. +Below is an example configuration: + +```json +{ + "OrchardCore": { + "Azure": { + "Credentials": { + "Default": { + "AuthenticationType": "ManagedIdentity", + "ClientId": "" // Optional: If omitted, the system-assigned managed identity will be used. + }, + "AnyCustomName": { + "AuthenticationType": "Default" // Uses the special Default credential + }, + "SearchAI": { + "Host": "", + "AuthenticationType": "ApiKey", + "ApiKey": "", + "DeploymentName": "" + } + } + } + } +} +``` + +--- + +## Notes + +* **Credential names are fully customizable** (e.g., `SearchAI`, `StorageAccount`). +* If a module does not specify which credential to use, it **may fall back to `"Default"`**. +* You can define **custom properties** within any credential, which can be retrieved programmatically using `GetProperty(propertyName)`. + +--- + +## Obtaining Credentials + +You can obtain credentials for a specific Azure service using the `AzureOptions` provider and `TokenCredential`: + +```csharp +public sealed class Example +{ + private readonly IOptionsMonitor _options; + + public Example(IOptionsMonitor options) + { + _options = options; + } + + public async Task GetTokenAsync() + { + // Get the named credential (falls back to "Default" if not specified). + var options = _options.Get("AnyCustomName"); + + // Access a custom property called "Scopes" defined in the configuration. + var scopes = options.GetProperty("Scopes"); + + if (scopes is null || scopes.Length == 0) + { + throw new InvalidOperationException("Scopes must be defined in the configuration for the Redis credential."); + } + + // Create the appropriate credential based on the authentication type. + TokenCredential credential = options.ToTokenCredential(); + + if (credential is null) + { + throw new InvalidOperationException("Failed to create a TokenCredential from the Redis options."); + } + + var result = await credential.GetTokenAsync(new TokenRequestContext(scopes), CancellationToken.None); + + return result.Token; + } +} +``` diff --git a/src/docs/reference/modules/Redis.Azure/README.md b/src/docs/reference/modules/Redis.Azure/README.md new file mode 100644 index 00000000000..ccdda55ae74 --- /dev/null +++ b/src/docs/reference/modules/Redis.Azure/README.md @@ -0,0 +1,44 @@ +# Redis Module (`OrchardCore.Redis.Azure`) + +The **Redis Azure Module** depends on `OrchardCore.Redis` and enables your application to connect to a Redis cache hosted in Azure. +It supports authentication using Azure credentials, including: + +* **Managed Identity** +* **Azure CLI (`AzureCli`)** +* **Default credentials (`Default`)** + +--- + +## Configuration + +Add a `Redis` credential entry to your Azure credentials configuration (e.g., `appsettings.json`): + +```json +{ + "OrchardCore": { + "Azure": { + "Credentials": { + "Redis": { + "AuthenticationType": "ManagedIdentity", // Supported types: Default, ManagedIdentity, AzureCli, ApiKey + "ClientId": "", // Optional: Only needed for user-assigned managed identity + "Scopes": ["https://*.redis.cache.windows.net/.default"] + } + } + } + } +} +``` + +--- + +## Notes + +* **Credential Name Requirement:** The Redis module **requires** the Azure credential to be named `Redis`. Other names will not be recognized. +* **AuthenticationType Options:** + + * **Default** – Uses `DefaultAzureCredential` (auto-detects available credentials). + * **ManagedIdentity** – Uses a system-assigned or user-assigned managed identity. + * **AzureCli** – Uses the currently logged-in Azure CLI session. + * **ApiKey** – Uses an explicit API key (less common for Redis). +* **ClientId** is only required for **user-assigned managed identities**. If omitted, the system-assigned managed identity will be used. +* You can also include **custom properties** in the `Redis` credential entry. These can be accessed at runtime using the `GetProperty(propertyName)` method. diff --git a/src/docs/releases/3.0.0.md b/src/docs/releases/3.0.0.md index c368dc68db8..41f1e919bf5 100644 --- a/src/docs/releases/3.0.0.md +++ b/src/docs/releases/3.0.0.md @@ -4,6 +4,8 @@ Release date: Not yet released Before upgrading from version 2 to v3, it is important to first compile your project using the latest available v2 version, resolve any warnings, and then proceed with the upgrade to v3. +--- + ## Breaking Changes ### Content Module @@ -164,6 +166,7 @@ With the improvement done to the Indexing module, managing Lucene indexes is now There are lots of binary breaking changes in the Azure Search AI module most won't impact you. The following change will impact you: - The permission `AzureAISearchPermissions.ManageAzureAISearchIndexes` was removed and the `AzureAISearchPermissions.ManageAzureAISearchISettings` was added. +- The `AzureAIAuthenticationType` was removed. You may use the new `AzureAuthenticationType` found in `OrchardCore.Azure.Core` project.` ### Taxonomies Module @@ -321,6 +324,8 @@ To resolve CS0433 naming conflicts, the `OrchardRazorHelperExtensions` classes a **Impact**: Extension methods continue to work identically in Razor views (`@Orchard.AssetUrl()`, `@Orchard.ConsoleLog()`, etc.). This change only affects projects that directly reference these class names in code. +--- + ## Change Log ### Localization Module @@ -508,6 +513,24 @@ The following recipe steps were deprecated: Font Awesome was updated to version 7.0.0. Some changes may be required in your templates. Please follow the instructions from [the changelog](https://fontawesome.com/changelog). +--- + +## New Features + +The following features have been added in this release: + +### Azure Credentials Module + +Manage all your Azure credentials in a centralized location, making them available to any Orchard Core module that requires them. +For more information, see the [Azure module documentation](../reference/modules/Azure/README.md). + +### Azure Redis Cache Integration + +Connect Orchard Core to an **Azure Redis Cache** instance for caching and distributed data storage. +For detailed instructions, see the [Redis Azure module documentation](../reference/modules/Redis.Azure/README.md). + +--- + ## Miscellaneous ### Sealing Types