From d98c12e6e072c4bb64562fd156acea9283ae3499 Mon Sep 17 00:00:00 2001 From: Hisham Bin Ateya Date: Mon, 18 Mar 2024 16:58:36 +0300 Subject: [PATCH] Suuport Azure Communication SMS --- Directory.Packages.props | 1 + OrchardCore.sln | 7 + .../Activities/SmsTask.cs | 68 +++++++++ .../OrchardCore.Sms.Azure/AdminMenu.cs | 54 +++++++ .../Controllers/AdminController.cs | 114 +++++++++++++++ .../Drivers/AzureSettingsDisplayDriver.cs | 136 ++++++++++++++++++ .../Drivers/SmsSettingsDisplayDriver.cs | 92 ++++++++++++ .../Drivers/SmsTaskDisplayDriver.cs | 69 +++++++++ .../OrchardCore.Sms.Azure/Manifest.cs | 26 ++++ .../Models/AzureSettings.cs | 10 ++ .../OrchardCore.Sms.Azure.csproj | 31 ++++ .../Services/AzureSmsProvider.cs | 102 +++++++++++++ .../SmsPermissionProvider.cs | 27 ++++ .../OrchardCore.Sms.Azure/SmsPermissions.cs | 8 ++ .../OrchardCore.Sms.Azure/Startup.cs | 60 ++++++++ .../ViewModels/AzureSettingsViewModel.cs | 10 ++ .../ViewModels/SmsSettingsBaseViewModel.cs | 6 + .../ViewModels/SmsSettingsViewModel.cs | 10 ++ .../ViewModels/SmsTaskViewModel.cs | 8 ++ .../ViewModels/SmsTestViewModel.cs | 18 +++ .../Views/Admin/Test.cshtml | 40 ++++++ .../Views/AzureSettings.Edit.cshtml | 35 +++++ .../Views/Items/SmsTask.Fields.Design.cshtml | 12 ++ .../Views/Items/SmsTask.Fields.Edit.cshtml | 35 +++++ .../Items/SmsTask.Fields.Thumbnail.cshtml | 4 + .../Views/NavigationItemText-sms.Id.cshtml | 4 + .../Views/SmsSettings.Edit.cshtml | 11 ++ .../Views/_ViewImports.cshtml | 5 + 28 files changed, 1003 insertions(+) create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Activities/SmsTask.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/AdminMenu.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Controllers/AdminController.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/AzureSettingsDisplayDriver.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/SmsSettingsDisplayDriver.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/SmsTaskDisplayDriver.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Manifest.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Models/AzureSettings.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/OrchardCore.Sms.Azure.csproj create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProvider.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/SmsPermissionProvider.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/SmsPermissions.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Startup.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/AzureSettingsViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsSettingsBaseViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsSettingsViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsTaskViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsTestViewModel.cs create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Admin/Test.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/AzureSettings.Edit.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Design.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Edit.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Thumbnail.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/NavigationItemText-sms.Id.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/SmsSettings.Edit.cshtml create mode 100644 src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/_ViewImports.cshtml diff --git a/Directory.Packages.props b/Directory.Packages.props index 110df2fa9a0..32fcdd399d0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/OrchardCore.sln b/OrchardCore.sln index e2b57d6f876..172cbd5cfc9 100644 --- a/OrchardCore.sln +++ b/OrchardCore.sln @@ -525,6 +525,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Rules.Core", "s EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Queries.Core", "src\OrchardCore\OrchardCore.Queries.Core\OrchardCore.Queries.Core.csproj", "{61B358F2-702C-40AA-9DF7-7121248FE6DE}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Sms.Azure", "src\OrchardCore.Modules\OrchardCore.Sms.Azure\OrchardCore.Sms.Azure.csproj", "{013C8BBF-6879-4B47-80C9-A466923E45E5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1389,6 +1391,10 @@ Global {61B358F2-702C-40AA-9DF7-7121248FE6DE}.Debug|Any CPU.Build.0 = Debug|Any CPU {61B358F2-702C-40AA-9DF7-7121248FE6DE}.Release|Any CPU.ActiveCfg = Release|Any CPU {61B358F2-702C-40AA-9DF7-7121248FE6DE}.Release|Any CPU.Build.0 = Release|Any CPU + {013C8BBF-6879-4B47-80C9-A466923E45E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {013C8BBF-6879-4B47-80C9-A466923E45E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {013C8BBF-6879-4B47-80C9-A466923E45E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {013C8BBF-6879-4B47-80C9-A466923E45E5}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1629,6 +1635,7 @@ Global {E8A1097D-A65A-4B17-A3A2-F50D79552732} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} {4BAA08A2-878C-4B96-86BF-5B3DB2B6C2C7} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} {61B358F2-702C-40AA-9DF7-7121248FE6DE} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} + {013C8BBF-6879-4B47-80C9-A466923E45E5} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {46A1D25A-78D1-4476-9CBF-25B75E296341} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Activities/SmsTask.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Activities/SmsTask.cs new file mode 100644 index 00000000000..0681c89930c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Activities/SmsTask.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Localization; +using OrchardCore.Workflows.Abstractions.Models; +using OrchardCore.Workflows.Activities; +using OrchardCore.Workflows.Models; +using OrchardCore.Workflows.Services; + +namespace OrchardCore.Sms.Azure.Activities; + +public class SmsTask : TaskActivity +{ + private readonly ISmsService _smsService; + private readonly IWorkflowExpressionEvaluator _expressionEvaluator; + protected readonly IStringLocalizer S; + + public SmsTask( + ISmsService smsService, + IWorkflowExpressionEvaluator expressionEvaluator, + IStringLocalizer stringLocalizer + ) + { + _smsService = smsService; + _expressionEvaluator = expressionEvaluator; + S = stringLocalizer; + } + + public override LocalizedString DisplayText => S["SMS Task"]; + + public override LocalizedString Category => S["Messaging"]; + + public WorkflowExpression PhoneNumber + { + get => GetProperty(() => new WorkflowExpression()); + set => SetProperty(value); + } + + public WorkflowExpression Body + { + get => GetProperty(() => new WorkflowExpression()); + set => SetProperty(value); + } + + public override IEnumerable GetPossibleOutcomes(WorkflowExecutionContext workflowContext, ActivityContext activityContext) + { + return Outcomes(S["Done"], S["Failed"]); + } + + public override async Task ExecuteAsync(WorkflowExecutionContext workflowContext, ActivityContext activityContext) + { + var message = new SmsMessage + { + To = await _expressionEvaluator.EvaluateAsync(PhoneNumber, workflowContext, null), + Body = await _expressionEvaluator.EvaluateAsync(Body, workflowContext, null), + }; + + var result = await _smsService.SendAsync(message); + + workflowContext.LastResult = result; + + if (result.Succeeded) + { + return Outcomes("Done"); + } + + return Outcomes("Failed"); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/AdminMenu.cs new file mode 100644 index 00000000000..22bc4290c6c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/AdminMenu.cs @@ -0,0 +1,54 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using OrchardCore.Mvc.Core.Utilities; +using OrchardCore.Navigation; +using OrchardCore.Sms.Azure.Controllers; + +namespace OrchardCore.Sms.Azure; + +public class AdminMenu : INavigationProvider +{ + private static readonly RouteValueDictionary _routeValues = new() + { + { "area", "OrchardCore.Settings" }, + { "groupId", SmsSettings.GroupId }, + }; + + protected readonly IStringLocalizer S; + + public AdminMenu(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + public Task BuildNavigationAsync(string name, NavigationBuilder builder) + { + if (!NavigationHelper.IsAdminMenu(name)) + { + return Task.CompletedTask; + } + + builder + .Add(S["Configuration"], configuration => configuration + .Add(S["Settings"], settings => settings + .Add(S["SMS"], S["SMS"].PrefixPosition(), sms => sms + .AddClass("sms") + .Id("sms") + .Action("Index", "Admin", _routeValues) + .Permission(SmsPermissions.ManageSmsSettings) + .LocalNav() + ) + .Add(S["SMS Test"], S["SMS Test"].PrefixPosition(), sms => sms + .AddClass("smstest") + .Id("smstest") + .Action(nameof(AdminController.Test), typeof(AdminController).ControllerName(), "OrchardCore.Sms") + .Permission(SmsPermissions.ManageSmsSettings) + .LocalNav() + ) + ) + ); + + return Task.CompletedTask; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Controllers/AdminController.cs new file mode 100644 index 00000000000..531619f417d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Controllers/AdminController.cs @@ -0,0 +1,114 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Sms.Azure.ViewModels; + +namespace OrchardCore.Sms.Azure.Controllers; + +public class AdminController : Controller +{ + private readonly SmsProviderOptions _smsProviderOptions; + private readonly IPhoneFormatValidator _phoneFormatValidator; + private readonly INotifier _notifier; + private readonly IAuthorizationService _authorizationService; + private readonly ISmsProviderResolver _smsProviderResolver; + + protected readonly IHtmlLocalizer H; + protected readonly IStringLocalizer S; + + public AdminController( + IOptions smsProviderOptions, + IPhoneFormatValidator phoneFormatValidator, + ISmsProviderResolver smsProviderResolver, + INotifier notifier, + IAuthorizationService authorizationService, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer) + { + _smsProviderOptions = smsProviderOptions.Value; + _phoneFormatValidator = phoneFormatValidator; + _smsProviderResolver = smsProviderResolver; + _notifier = notifier; + _authorizationService = authorizationService; + H = htmlLocalizer; + S = stringLocalizer; + } + + [Admin("sms/test", "SmsProviderTest")] + public async Task Test() + { + if (!await _authorizationService.AuthorizeAsync(User, SmsPermissions.ManageSmsSettings)) + { + return Forbid(); + } + + var model = new SmsTestViewModel(); + + PopulateModel(model); + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Test(SmsTestViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, SmsPermissions.ManageSmsSettings)) + { + return Forbid(); + } + + if (ModelState.IsValid) + { + var provider = await _smsProviderResolver.GetAsync(model.Provider); + + if (provider is null) + { + ModelState.AddModelError(nameof(model.Provider), S["Please select a valid provider."]); + } + else if (!_phoneFormatValidator.IsValid(model.PhoneNumber)) + { + ModelState.AddModelError(nameof(model.PhoneNumber), S["Please provide a valid phone number."]); + } + else + { + var result = await provider.SendAsync(new SmsMessage() + { + To = model.PhoneNumber, + Body = S["This is a test SMS message."] + }); + + if (result.Succeeded) + { + await _notifier.SuccessAsync(H["The test SMS message has been successfully sent."]); + + return RedirectToAction(nameof(Test)); + } + else + { + await _notifier.ErrorAsync(H["The test SMS message failed to send."]); + } + } + } + + PopulateModel(model); + + return View(model); + } + + private void PopulateModel(SmsTestViewModel model) + { + model.Providers = _smsProviderOptions.Providers + .Where(entry => entry.Value.IsEnabled) + .Select(entry => new SelectListItem(entry.Key, entry.Key)) + .OrderBy(item => item.Text) + .ToArray(); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/AzureSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/AzureSettingsDisplayDriver.cs new file mode 100644 index 00000000000..6b62289dbcf --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/AzureSettingsDisplayDriver.cs @@ -0,0 +1,136 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Entities; +using OrchardCore.Environment.Shell; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.Settings; +using OrchardCore.Sms.Azure.Models; +using OrchardCore.Sms.Azure.Services; +using OrchardCore.Sms.Azure.ViewModels; + +namespace OrchardCore.Sms.Azure.Drivers; + +public class AzureSettingsDisplayDriver : SectionDisplayDriver +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + private readonly IPhoneFormatValidator _phoneFormatValidator; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly IShellHost _shellHost; + private readonly ShellSettings _shellSettings; + private readonly INotifier _notifier; + + protected readonly IHtmlLocalizer H; + protected readonly IStringLocalizer S; + + public AzureSettingsDisplayDriver( + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService, + IPhoneFormatValidator phoneFormatValidator, + IDataProtectionProvider dataProtectionProvider, + IShellHost shellHost, + ShellSettings shellSettings, + INotifier notifier, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer) + { + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + _phoneFormatValidator = phoneFormatValidator; + _dataProtectionProvider = dataProtectionProvider; + _shellHost = shellHost; + _shellSettings = shellSettings; + _notifier = notifier; + H = htmlLocalizer; + S = stringLocalizer; + } + + public override IDisplayResult Edit(AzureSettings settings) + { + return Initialize("TwilioSettings_Edit", model => + { + model.IsEnabled = settings.IsEnabled; + model.ConnectionString = settings.ConnectionString; + model.PhoneNumber = settings.PhoneNumber; + }).Location("Content:5#Twilio") + .RenderWhen(() => _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, SmsPermissions.ManageSmsSettings)) + .OnGroup(SmsSettings.GroupId); + } + + public override async Task UpdateAsync(ISite site, AzureSettings settings, IUpdateModel updater, BuildEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (!context.GroupId.Equals(SmsSettings.GroupId, StringComparison.OrdinalIgnoreCase) + || !await _authorizationService.AuthorizeAsync(user, SmsPermissions.ManageSmsSettings)) + { + return null; + } + + var model = new AzureSettingsViewModel(); + + if (await context.Updater.TryUpdateModelAsync(model, Prefix)) + { + var hasChanges = settings.IsEnabled != model.IsEnabled; + + if (!model.IsEnabled) + { + var smsSettings = site.As(); + + if (hasChanges && smsSettings.DefaultProviderName == AzureSmsProvider.TechnicalName) + { + await _notifier.WarningAsync(H["You have successfully disabled the default SMS provider. The SMS service is now disable and will remain disabled until you designate a new default provider."]); + + smsSettings.DefaultProviderName = null; + + site.Put(smsSettings); + } + + settings.IsEnabled = false; + } + else + { + settings.IsEnabled = true; + + if (string.IsNullOrWhiteSpace(model.ConnectionString)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.ConnectionString), S["ConnectionString requires a value."]); + } + + if (string.IsNullOrWhiteSpace(model.PhoneNumber)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.PhoneNumber), S["Phone number requires a value."]); + } + else if (!_phoneFormatValidator.IsValid(model.PhoneNumber)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.PhoneNumber), S["Please provide a valid phone number."]); + } + + // Has change should be evaluated before updating the value. + hasChanges |= settings.ConnectionString != model.ConnectionString; + hasChanges |= settings.PhoneNumber != model.PhoneNumber; + + settings.ConnectionString = model.ConnectionString; + settings.PhoneNumber = model.PhoneNumber; + } + + if (context.Updater.ModelState.IsValid && hasChanges) + { + await _shellHost.ReleaseShellContextAsync(_shellSettings); + } + } + + return Edit(settings); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/SmsSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/SmsSettingsDisplayDriver.cs new file mode 100644 index 00000000000..630d854e045 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/SmsSettingsDisplayDriver.cs @@ -0,0 +1,92 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Environment.Shell; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.Settings; +using OrchardCore.Sms.Azure.ViewModels; + +namespace OrchardCore.Sms.Azure.Drivers; + +public class SmsSettingsDisplayDriver : SectionDisplayDriver +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + private readonly IShellHost _shellHost; + private readonly ShellSettings _shellSettings; + + protected IStringLocalizer S; + + private readonly SmsProviderOptions _smsProviderOptions; + + public SmsSettingsDisplayDriver( + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService, + IShellHost shellHost, + IOptions smsProviders, + ShellSettings shellSettings, + IStringLocalizer stringLocalizer) + { + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + _shellHost = shellHost; + _smsProviderOptions = smsProviders.Value; + _shellSettings = shellSettings; + S = stringLocalizer; + } + + public override IDisplayResult Edit(SmsSettings settings) + => Initialize("SmsSettings_Edit", model => + { + model.DefaultProvider = settings.DefaultProviderName; + model.Providers = _smsProviderOptions.Providers + .Where(entry => entry.Value.IsEnabled) + .Select(entry => new SelectListItem(entry.Key, entry.Key)) + .OrderBy(item => item.Text) + .ToArray(); + + }).Location("Content:1#Providers") + .RenderWhen(() => _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, SmsPermissions.ManageSmsSettings)) + .OnGroup(SmsSettings.GroupId); + + public override async Task UpdateAsync(SmsSettings settings, BuildEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (!context.GroupId.Equals(SmsSettings.GroupId, StringComparison.OrdinalIgnoreCase) + || !await _authorizationService.AuthorizeAsync(user, SmsPermissions.ManageSmsSettings)) + { + return null; + } + + var model = new SmsSettingsViewModel(); + + if (await context.Updater.TryUpdateModelAsync(model, Prefix)) + { + if (string.IsNullOrEmpty(model.DefaultProvider)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.DefaultProvider), S["You must select a default provider."]); + } + else + { + if (settings.DefaultProviderName != model.DefaultProvider) + { + settings.DefaultProviderName = model.DefaultProvider; + + await _shellHost.ReleaseShellContextAsync(_shellSettings); + } + } + } + + return Edit(settings); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/SmsTaskDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/SmsTaskDisplayDriver.cs new file mode 100644 index 00000000000..b95a6da6dff --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Drivers/SmsTaskDisplayDriver.cs @@ -0,0 +1,69 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Liquid; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.Sms.Azure.Activities; +using OrchardCore.Sms.Azure.ViewModels; +using OrchardCore.Workflows.Display; +using OrchardCore.Workflows.Models; + +namespace OrchardCore.Sms.Azure.Drivers; + +public class SmsTaskDisplayDriver : ActivityDisplayDriver +{ + private readonly IPhoneFormatValidator _phoneFormatValidator; + private readonly ILiquidTemplateManager _liquidTemplateManager; + + protected readonly IStringLocalizer S; + + public SmsTaskDisplayDriver( + IPhoneFormatValidator phoneFormatValidator, + ILiquidTemplateManager liquidTemplateManager, + IStringLocalizer stringLocalizer + ) + { + _phoneFormatValidator = phoneFormatValidator; + _liquidTemplateManager = liquidTemplateManager; + S = stringLocalizer; + } + + protected override void EditActivity(SmsTask activity, SmsTaskViewModel model) + { + model.PhoneNumber = activity.PhoneNumber.Expression; + model.Body = activity.Body.Expression; + } + + public async override Task UpdateAsync(SmsTask activity, IUpdateModel updater) + { + var viewModel = new SmsTaskViewModel(); + + if (await updater.TryUpdateModelAsync(viewModel, Prefix)) + { + if (string.IsNullOrWhiteSpace(viewModel.PhoneNumber)) + { + updater.ModelState.AddModelError(Prefix, nameof(viewModel.PhoneNumber), S["Phone number requires a value."]); + } + else if (!_phoneFormatValidator.IsValid(viewModel.PhoneNumber)) + { + updater.ModelState.AddModelError(Prefix, nameof(viewModel.PhoneNumber), S["Invalid phone number used."]); + } + + if (string.IsNullOrWhiteSpace(viewModel.Body)) + { + updater.ModelState.AddModelError(Prefix, nameof(viewModel.Body), S["Message Body requires a value."]); + } + else if (!_liquidTemplateManager.Validate(viewModel.Body, out var bodyErrors)) + { + updater.ModelState.AddModelError(Prefix, nameof(viewModel.Body), string.Join(' ', bodyErrors)); + } + + activity.PhoneNumber = new WorkflowExpression(viewModel.PhoneNumber); + activity.Body = new WorkflowExpression(viewModel.Body); + } + + return Edit(activity); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Manifest.cs new file mode 100644 index 00000000000..79948c20652 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Manifest.cs @@ -0,0 +1,26 @@ +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Author = ManifestConstants.OrchardCoreTeam, + Website = ManifestConstants.OrchardCoreWebsite, + Version = ManifestConstants.OrchardCoreVersion +)] + +[assembly: Feature( + Name = "SMS", + Id = "OrchardCore.Sms", + Description = "Provides settings and services to send SMS messages.", + Category = "SMS" +)] + +[assembly: Feature( + Name = "SMS Notifications", + Id = "OrchardCore.Notifications.Sms", + Description = "Provides a way to send SMS notifications to users.", + Category = "Notifications", + Dependencies = + [ + "OrchardCore.Notifications", + "OrchardCore.Sms", + ] +)] diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Models/AzureSettings.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Models/AzureSettings.cs new file mode 100644 index 00000000000..b2477b83cac --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Models/AzureSettings.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Sms.Azure.Models; + +public class AzureSettings +{ + public bool IsEnabled { get; set; } + + public string ConnectionString { get; set; } + + public string PhoneNumber { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/OrchardCore.Sms.Azure.csproj b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/OrchardCore.Sms.Azure.csproj new file mode 100644 index 00000000000..2712a4525ca --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/OrchardCore.Sms.Azure.csproj @@ -0,0 +1,31 @@ + + + + true + + OrchardCore Sms + + $(OCCMSDescription) + + The SMS module provides a way to send SMS messages. + + $(PackageTags) OrchardCoreCMS + + + + + + + + + + + + + + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProvider.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProvider.cs new file mode 100644 index 00000000000..f1f7cdc37d5 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Services/AzureSmsProvider.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Azure.Communication.Sms; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using OrchardCore.Settings; +using OrchardCore.Sms.Azure.Models; + +namespace OrchardCore.Sms.Azure.Services; + +public class AzureSmsProvider : ISmsProvider +{ + private readonly ISiteService _siteService; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly ILogger _logger; + + private AzureSettings _settings; + + protected readonly IStringLocalizer S; + + public const string TechnicalName = "Azure"; + + public const string ProtectorName = "Azure"; + + public LocalizedString Name => S["Azure"]; + + public AzureSmsProvider( + ISiteService siteService, + IDataProtectionProvider dataProtectionProvider, + ILogger logger, + IStringLocalizer stringLocalizer) + { + _siteService = siteService; + _dataProtectionProvider = dataProtectionProvider; + _logger = logger; + S = stringLocalizer; + } + + public async Task SendAsync(SmsMessage message) + { + ArgumentNullException.ThrowIfNull(message); + + if (string.IsNullOrEmpty(message.To)) + { + throw new ArgumentException("A phone number is required in order to send a message."); + } + + if (string.IsNullOrEmpty(message.Body)) + { + throw new ArgumentException("A message body is required in order to send a message."); + } + + try + { + var settings = await GetSettingsAsync(); + var data = new List> + { + new ("From", settings.PhoneNumber), + new ("To", message.To), + new ("Body", message.Body), + }; + + var client = new SmsClient(settings.ConnectionString); + var response = await client.SendAsync(settings.PhoneNumber, message.To, message.Body); + + if (response.Value.Successful) + { + return SmsResult.Success; + } + else + { + return SmsResult.Failed(S["SMS message was not send."]); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Azure service was unable to send SMS messages."); + + return SmsResult.Failed(S["SMS message was not send. Error: {0}", ex.Message]); + } + } + + private async Task GetSettingsAsync() + { + if (_settings == null) + { + var settings = (await _siteService.GetSiteSettingsAsync()).As(); + + var protector = _dataProtectionProvider.CreateProtector(ProtectorName); + + _settings = new AzureSettings + { + ConnectionString = settings.ConnectionString == null ? null : protector.Unprotect(settings.ConnectionString), + PhoneNumber = settings.PhoneNumber + }; + } + + return _settings; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/SmsPermissionProvider.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/SmsPermissionProvider.cs new file mode 100644 index 00000000000..a3f65d82404 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/SmsPermissionProvider.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Sms.Azure; + +public class SmsPermissionProvider : IPermissionProvider +{ + public static readonly Permission ManageSmsSettings = SmsPermissions.ManageSmsSettings; + + private readonly IEnumerable _allPermissions = + [ + ManageSmsSettings, + ]; + + public Task> GetPermissionsAsync() + => Task.FromResult(_allPermissions); + + public IEnumerable GetDefaultStereotypes() => + [ + new PermissionStereotype + { + Name = "Administrator", + Permissions = _allPermissions, + }, + ]; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/SmsPermissions.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/SmsPermissions.cs new file mode 100644 index 00000000000..e88ec15476e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/SmsPermissions.cs @@ -0,0 +1,8 @@ +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Sms.Azure; + +public class SmsPermissions +{ + public static readonly Permission ManageSmsSettings = new("ManageSmsSettings", "Manage SMS Settings"); +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Startup.cs new file mode 100644 index 00000000000..35eefa83b57 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Startup.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Modules; +using OrchardCore.Navigation; +using OrchardCore.Notifications; +using OrchardCore.Security.Permissions; +using OrchardCore.Settings; +using OrchardCore.Sms.Azure.Activities; +using OrchardCore.Sms.Azure.Drivers; +using OrchardCore.Sms.Services; +using OrchardCore.Workflows.Helpers; + +namespace OrchardCore.Sms.Azure; + +public class Startup : StartupBase +{ + private readonly IHostEnvironment _hostEnvironment; + + public Startup(IHostEnvironment hostEnvironment) + { + _hostEnvironment = hostEnvironment; + } + + public override void ConfigureServices(IServiceCollection services) + { + services.AddSmsServices(); + services.AddPhoneFormatValidator(); + + if (_hostEnvironment.IsDevelopment()) + { + services.AddLogSmsProvider(); + } + + services.AddTwilioSmsProvider() + .AddScoped, AzureSettingsDisplayDriver>(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped, SmsSettingsDisplayDriver>(); + } +} + +[Feature("OrchardCore.Notifications.Sms")] +public class NotificationsStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + } +} + +[RequireFeatures("OrchardCore.Workflows")] +public class WorkflowsStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddActivity(); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/AzureSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/AzureSettingsViewModel.cs new file mode 100644 index 00000000000..fe82ff10b39 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/AzureSettingsViewModel.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Sms.Azure.ViewModels; + +public class AzureSettingsViewModel : SmsSettingsBaseViewModel +{ + public bool IsEnabled { get; set; } + + public string ConnectionString { get; set; } + + public string PhoneNumber { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsSettingsBaseViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsSettingsBaseViewModel.cs new file mode 100644 index 00000000000..a98ab23a336 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsSettingsBaseViewModel.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Sms.Azure.ViewModels; + +public class SmsSettingsBaseViewModel +{ + public string DefaultProvider { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsSettingsViewModel.cs new file mode 100644 index 00000000000..086f601b03a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsSettingsViewModel.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace OrchardCore.Sms.Azure.ViewModels; + +public class SmsSettingsViewModel : SmsSettingsBaseViewModel +{ + [BindNever] + public SelectListItem[] Providers { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsTaskViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsTaskViewModel.cs new file mode 100644 index 00000000000..8e7661a9646 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsTaskViewModel.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Sms.Azure.ViewModels; + +public class SmsTaskViewModel +{ + public string PhoneNumber { get; set; } + + public string Body { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsTestViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsTestViewModel.cs new file mode 100644 index 00000000000..d3778587aea --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/ViewModels/SmsTestViewModel.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace OrchardCore.Sms.Azure.ViewModels; + +public class SmsTestViewModel +{ + [Required] + public string Provider { get; set; } + + [Required] + public string PhoneNumber { get; set; } + + [BindNever] + public IList Providers { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Admin/Test.cshtml b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Admin/Test.cshtml new file mode 100644 index 00000000000..803ac540110 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Admin/Test.cshtml @@ -0,0 +1,40 @@ +@using OrchardCore +@using OrchardCore.Sms.ViewModels + +@model SmsTestViewModel + +@if (Model.Providers == null || Model.Providers.Count == 0) +{ + + + return; +} + +
+ +
+ +
+ +
+
+ +
+ +
+ + @T["Phone number must include a country code. For example, +1 for United States."] +
+
+ +
+
+ +
+
+ +
diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/AzureSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/AzureSettings.Edit.cshtml new file mode 100644 index 00000000000..74565790710 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/AzureSettings.Edit.cshtml @@ -0,0 +1,35 @@ +@using OrchardCore.Sms.Azure.Services +@using OrchardCore.Sms.Azure.ViewModels +@using OrchardCore.Sms + +@model AzureSettingsViewModel + +
+
+ + +
+
+ +
+ +

@T["Azure Account Info"]

+ +
+ + + + @if (Model.ConnectionString) + { + @T["Connection String was securely saved. Enter a new value if you wish to replace the existing secret."] + } +
+ +
+ + + + @T["Phone number must include a country code. For example, +1 for United States."] +
+ +
diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Design.cshtml b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Design.cshtml new file mode 100644 index 00000000000..5ed5b7fc91d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Design.cshtml @@ -0,0 +1,12 @@ +@using OrchardCore.Workflows.ViewModels +@using OrchardCore.Sms.Activities +@using OrchardCore.Workflows.Helpers + +@model ActivityViewModel + +
+

+ @Model.Activity.GetTitleOrDefault(() => T["Send SMS"]) +

+
+@Model.Activity.Body diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Edit.cshtml new file mode 100644 index 00000000000..d16da5683ef --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Edit.cshtml @@ -0,0 +1,35 @@ +@using OrchardCore.Sms.ViewModels + +@model SmsTaskViewModel + +
+ + + + @T["Phone number must include a country code. For example, +1 for United States."] +
+ +
+ + + @T["The body of the SMS message. With Liquid support."] +
+ + + + + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Thumbnail.cshtml b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Thumbnail.cshtml new file mode 100644 index 00000000000..6b19ed47884 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/Items/SmsTask.Fields.Thumbnail.cshtml @@ -0,0 +1,4 @@ +

+ @T["Send SMS"] +

+

@T["Send an SMS message."]

diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/NavigationItemText-sms.Id.cshtml b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/NavigationItemText-sms.Id.cshtml new file mode 100644 index 00000000000..fadfbabe7a8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/NavigationItemText-sms.Id.cshtml @@ -0,0 +1,4 @@ + + + +@T["SMS"] diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/SmsSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/SmsSettings.Edit.cshtml new file mode 100644 index 00000000000..8591efef847 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/SmsSettings.Edit.cshtml @@ -0,0 +1,11 @@ +@using OrchardCore.Sms.ViewModels + +@model SmsSettingsViewModel + +
+ + + +
diff --git a/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/_ViewImports.cshtml b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/_ViewImports.cshtml new file mode 100644 index 00000000000..252fd654bb8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Sms.Azure/Views/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@inherits OrchardCore.DisplayManagement.Razor.RazorPage + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement