diff --git a/Oqtane.Client/Modules/Admin/Site/Index.razor b/Oqtane.Client/Modules/Admin/Site/Index.razor index 525f45df..59e10be5 100644 --- a/Oqtane.Client/Modules/Admin/Site/Index.razor +++ b/Oqtane.Client/Modules/Admin/Site/Index.razor @@ -411,6 +411,18 @@ + @if (_rendermode == RenderModes.Static) + { +
+ +
+ +
+
+ }
@@ -537,6 +549,7 @@ private string _defaultalias; private string _rendermode = RenderModes.Interactive; + private string _enhancednavigation = "True"; private string _runtime = Runtimes.Server; private string _prerender = "True"; private string _hybrid = "False"; @@ -660,6 +673,7 @@ // hosting model _rendermode = site.RenderMode; + _enhancednavigation = site.EnhancedNavigation.ToString(); _runtime = site.Runtime; _prerender = site.Prerender.ToString(); _hybrid = site.Hybrid.ToString(); @@ -807,13 +821,11 @@ // hosting model if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) { - if (site.RenderMode != _rendermode || site.Runtime != _runtime || site.Prerender != bool.Parse(_prerender) || site.Hybrid != bool.Parse(_hybrid)) - { - site.RenderMode = _rendermode; - site.Runtime = _runtime; - site.Prerender = bool.Parse(_prerender); - site.Hybrid = bool.Parse(_hybrid); - } + site.RenderMode = _rendermode; + site.EnhancedNavigation = bool.Parse(_enhancednavigation); + site.Runtime = _runtime; + site.Prerender = bool.Parse(_prerender); + site.Hybrid = bool.Parse(_hybrid); } site = await SiteService.UpdateSiteAsync(site); diff --git a/Oqtane.Client/Modules/Controls/RichTextEditor.razor b/Oqtane.Client/Modules/Controls/RichTextEditor.razor index 6933783a..e4e98fba 100644 --- a/Oqtane.Client/Modules/Controls/RichTextEditor.razor +++ b/Oqtane.Client/Modules/Controls/RichTextEditor.razor @@ -7,7 +7,7 @@ @inject ISettingService SettingService @inject IStringLocalizer Localizer -
+
@_textEditorComponent
@@ -18,6 +18,8 @@ private RenderFragment _textEditorComponent; private ITextEditor _textEditor; + private string _style = "margin-bottom: 50px;"; + [Parameter] public string Content { get; set; } @@ -30,6 +32,9 @@ [Parameter] public string Provider { get; set; } + [Parameter] + public string Style { get; set; } // optional + [Parameter(CaptureUnmatchedValues = true)] public Dictionary AdditionalAttributes { get; set; } = new Dictionary(); @@ -40,6 +45,12 @@ protected override void OnParametersSet() { + + if (!string.IsNullOrEmpty(Style)) + { + _style = Style; + } + _textEditorComponent = (builder) => { CreateTextEditor(builder); diff --git a/Oqtane.Client/Modules/ModuleBase.cs b/Oqtane.Client/Modules/ModuleBase.cs index 00dc2c61..264930c4 100644 --- a/Oqtane.Client/Modules/ModuleBase.cs +++ b/Oqtane.Client/Modules/ModuleBase.cs @@ -460,6 +460,11 @@ namespace Oqtane.Modules public string ReplaceTokens(string content, object obj) { + // check for null or empty content + if (string.IsNullOrEmpty(content)) + { + return content; + } // Using StringBuilder avoids the performance penalty of repeated string allocations // that occur with string.Replace or string concatenation inside loops. var sb = new StringBuilder(); diff --git a/Oqtane.Client/Resources/Modules/Admin/RecycleBin/Index.resx b/Oqtane.Client/Resources/Modules/Admin/RecycleBin/Index.resx index 0e212ab8..fbb57778 100644 --- a/Oqtane.Client/Resources/Modules/Admin/RecycleBin/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/RecycleBin/Index.resx @@ -204,7 +204,7 @@ Page Deleted Successfully - + All Pages Deleted Successfully diff --git a/Oqtane.Client/Resources/UI/ModuleInstance.resx b/Oqtane.Client/Resources/UI/ModuleInstance.resx index fc0994f2..4c4a4c63 100644 --- a/Oqtane.Client/Resources/UI/ModuleInstance.resx +++ b/Oqtane.Client/Resources/UI/ModuleInstance.resx @@ -124,6 +124,9 @@ Module Type Is Invalid For {0} - An Unexpected Error Has Occurred + An Unexpected Error Has Occurred + + + Missing service(s): {0}. Please make sure they have been registered correctly. \ No newline at end of file diff --git a/Oqtane.Client/UI/RenderModeBoundary.razor b/Oqtane.Client/UI/RenderModeBoundary.razor index abf96087..b6b5e0af 100644 --- a/Oqtane.Client/UI/RenderModeBoundary.razor +++ b/Oqtane.Client/UI/RenderModeBoundary.razor @@ -1,7 +1,11 @@ @namespace Oqtane.UI +@using System.Reflection +@using Module = Oqtane.Models.Module +@inject IServiceProvider ServiceProvider @inject SiteState ComponentSiteState @inject IStringLocalizer Localizer @inject ILogService LoggingService +@inject NavigationManager NavigationManager @inherits ErrorBoundary @@ -67,37 +71,50 @@ { if (ShouldRender()) { - if (!string.IsNullOrEmpty(ModuleState.ModuleType)) - { - ModuleType = Type.GetType(ModuleState.ModuleType); - if (ModuleType != null) - { - // repopulate the SiteState service based on the values passed in the SiteState parameter (this is how state is marshalled across the render mode boundary) - ComponentSiteState.Hydrate(SiteState); - - DynamicComponent = builder => - { - builder.OpenComponent(0, ModuleType); - builder.AddAttribute(1, "RenderModeBoundary", this); - builder.CloseComponent(); - }; - } - else - { - // module does not exist with typename specified - _messageContent = string.Format(Localizer["Error.Module.InvalidName"], Utilities.GetTypeNameLastSegment(ModuleState.ModuleType, 0)); - _messageType = MessageType.Error; - _messagePosition = "top"; - _messageStyle = MessageStyle.Alert; - } - } - else + if (string.IsNullOrEmpty(ModuleState.ModuleType)) { _messageContent = string.Format(Localizer["Error.Module.InvalidType"], ModuleState.ModuleDefinitionName); _messageType = MessageType.Error; _messagePosition = "top"; _messageStyle = MessageStyle.Alert; + + return; } + + ModuleType = Type.GetType(ModuleState.ModuleType); + var moduleName = Utilities.GetTypeNameLastSegment(ModuleState.ModuleType, 0); + if (ModuleType == null) + { + // module does not exist with typename specified + _messageContent = string.Format(Localizer["Error.Module.InvalidName"], moduleName); + _messageType = MessageType.Error; + _messagePosition = "top"; + _messageStyle = MessageStyle.Alert; + + return; + } + + //only validate the services injection in development environment + if (NavigationManager.BaseUri.Contains("localhost:") && !ValidateModuleTypeInjectedServices(ModuleType, out IList missingServices)) + { + // module type is not valid for instantiation + _messageContent = string.Format(Localizer["Error.Module.InvalidInjectedServices"], string.Join(",", missingServices)); + _messageType = MessageType.Error; + _messagePosition = "top"; + _messageStyle = MessageStyle.Alert; + + return; + } + + // repopulate the SiteState service based on the values passed in the SiteState parameter (this is how state is marshalled across the render mode boundary) + ComponentSiteState.Hydrate(SiteState); + + DynamicComponent = builder => + { + builder.OpenComponent(0, ModuleType); + builder.AddAttribute(1, "RenderModeBoundary", this); + builder.CloseComponent(); + }; } } @@ -165,4 +182,26 @@ _error = ""; base.Recover(); } + + private bool ValidateModuleTypeInjectedServices(Type moduleType, out IList missingServices) + { + missingServices = new List(); + + var properties = Utilities.GetPropertiesIncludingInherited(moduleType, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach(var property in properties) + { + var injectAttribute = property.GetCustomAttribute(typeof(InjectAttribute)); + if (injectAttribute != null) + { + var serviceType = property.PropertyType; + var service = ServiceProvider.GetService(serviceType); + if (serviceType != null && service == null) + { + missingServices.Add(Utilities.GetTypeNameLastSegment(serviceType.FullName, 0)); + } + } + } + + return !missingServices.Any(); + } } \ No newline at end of file diff --git a/Oqtane.Package/Oqtane.Server.nuspec b/Oqtane.Package/Oqtane.Server.nuspec index 789f9215..6e4219a3 100644 --- a/Oqtane.Package/Oqtane.Server.nuspec +++ b/Oqtane.Package/Oqtane.Server.nuspec @@ -31,7 +31,7 @@ - + diff --git a/Oqtane.Server/Components/App.razor b/Oqtane.Server/Components/App.razor index 333ed42f..0082e669 100644 --- a/Oqtane.Server/Components/App.razor +++ b/Oqtane.Server/Components/App.razor @@ -60,7 +60,7 @@ } @((MarkupString)_headResources) - + @if (string.IsNullOrEmpty(_message)) { @if (_renderMode == RenderModes.Static) @@ -97,6 +97,7 @@ private string _renderMode = RenderModes.Interactive; private string _runtime = Runtimes.Server; private bool _prerender = true; + private bool _enhancedNavigation = true; private string _fingerprint = ""; private int _visitorId = -1; private string _antiForgeryToken = ""; @@ -141,6 +142,7 @@ _renderMode = site.RenderMode; _runtime = site.Runtime; _prerender = site.Prerender; + _enhancedNavigation = site.EnhancedNavigation; _fingerprint = site.Fingerprint; var cookieConsentSettings = SettingService.GetSetting(site.Settings, "CookieConsent", string.Empty); diff --git a/Oqtane.Server/Controllers/PageController.cs b/Oqtane.Server/Controllers/PageController.cs index cb7b8ee5..69bed0bc 100644 --- a/Oqtane.Server/Controllers/PageController.cs +++ b/Oqtane.Server/Controllers/PageController.cs @@ -265,7 +265,19 @@ namespace Oqtane.Controllers _syncManager.AddSyncEvent(_alias, EntityNames.Site, page.SiteId, SyncEventActions.Refresh); // set user personalized page path - _settings.AddSetting(new Setting { EntityName = EntityNames.User, EntityId = page.UserId.Value, SettingName = $"PersonalizedPagePath:{page.SiteId}:{parent.PageId}", SettingValue = path, IsPrivate = false }); + var settingName = $"PersonalizedPagePath:{page.SiteId}:{parent.PageId}"; + var pathSetting = _settings.GetSetting(EntityNames.User, page.UserId.Value, settingName); + if(pathSetting == null) + { + pathSetting = new Setting { EntityName = EntityNames.User, EntityId = page.UserId.Value, SettingName = settingName, SettingValue = path, IsPrivate = false }; + _settings.AddSetting(pathSetting); + } + else + { + pathSetting.SettingValue = path; + _settings.UpdateSetting(pathSetting); + } + _syncManager.AddSyncEvent(_alias, EntityNames.User, user.UserId, SyncEventActions.Update); } } diff --git a/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs b/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs index 9d320bd9..a280c71f 100644 --- a/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs +++ b/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs @@ -186,7 +186,7 @@ namespace Oqtane.Infrastructure var mailboxAddressValidationError = ""; // sender - if (settingRepository.GetSettingValue(settings, "SMTPRelay", "False") != "True") + if ((settingRepository.GetSettingValue(settings, "SMTPRelay", "False") == "True") && string.IsNullOrEmpty(fromEmail)) { fromEmail = settingRepository.GetSettingValue(settings, "SMTPSender", ""); fromName = string.IsNullOrEmpty(fromName) ? site.Name : fromName; diff --git a/Oqtane.Server/Managers/UserManager.cs b/Oqtane.Server/Managers/UserManager.cs index 4e95d5f6..e6f2661d 100644 --- a/Oqtane.Server/Managers/UserManager.cs +++ b/Oqtane.Server/Managers/UserManager.cs @@ -279,7 +279,7 @@ namespace Oqtane.Managers await _identityUserManager.UpdateAsync(identityuser); // security stamp not updated } - if (bool.Parse(_settings.GetSettingValue(EntityNames.Site, alias.SiteId, "LoginOptions:RequireConfirmedEmail", "true"))) + if (bool.Parse(_settings.GetSettingValue(EntityNames.Site, alias.SiteId, "LoginOptions:RequireConfirmedEmail", "true")) && !user.IsDeleted) { if (user.EmailConfirmed) { diff --git a/Oqtane.Server/Migrations/Tenant/10000101_AddSiteEnhancedNavigation.cs b/Oqtane.Server/Migrations/Tenant/10000101_AddSiteEnhancedNavigation.cs new file mode 100644 index 00000000..b119c2ee --- /dev/null +++ b/Oqtane.Server/Migrations/Tenant/10000101_AddSiteEnhancedNavigation.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Oqtane.Databases.Interfaces; +using Oqtane.Migrations.EntityBuilders; +using Oqtane.Repository; + +namespace Oqtane.Migrations.Tenant +{ + [DbContext(typeof(TenantDBContext))] + [Migration("Tenant.10.00.01.01")] + public class AddSiteEnhancedNavigation : MultiDatabaseMigration + { + public AddSiteEnhancedNavigation(IDatabase database) : base(database) + { + } + + protected override void Up(MigrationBuilder migrationBuilder) + { + var siteEntityBuilder = new SiteEntityBuilder(migrationBuilder, ActiveDatabase); + siteEntityBuilder.AddBooleanColumn("EnhancedNavigation", true); + siteEntityBuilder.UpdateData("EnhancedNavigation", true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // not implemented + } + } +} diff --git a/Oqtane.Server/Oqtane.Server.csproj b/Oqtane.Server/Oqtane.Server.csproj index 1236dde0..d14efd87 100644 --- a/Oqtane.Server/Oqtane.Server.csproj +++ b/Oqtane.Server/Oqtane.Server.csproj @@ -43,7 +43,7 @@ - + diff --git a/Oqtane.Shared/Models/Site.cs b/Oqtane.Shared/Models/Site.cs index e674be2e..a00fa5b1 100644 --- a/Oqtane.Shared/Models/Site.cs +++ b/Oqtane.Shared/Models/Site.cs @@ -115,6 +115,11 @@ namespace Oqtane.Models /// public bool Hybrid { get; set; } + /// + /// Indicates if enhanced navigation should be used with static rendering + /// + public bool EnhancedNavigation { get; set; } + /// /// Keeps track of site configuration changes and is used by the ISiteMigration interface /// @@ -222,6 +227,7 @@ namespace Oqtane.Models Runtime = Runtime, Prerender = Prerender, Hybrid = Hybrid, + EnhancedNavigation = EnhancedNavigation, Version = Version, HomePageId = HomePageId, HeadContent = HeadContent, diff --git a/Oqtane.Shared/Shared/Utilities.cs b/Oqtane.Shared/Shared/Utilities.cs index 44cccffc..45888f0c 100644 --- a/Oqtane.Shared/Shared/Utilities.cs +++ b/Oqtane.Shared/Shared/Utilities.cs @@ -4,6 +4,8 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; +using System.Reflection; +using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; @@ -750,6 +752,75 @@ namespace Oqtane.Shared } } + public static IEnumerable GetPropertiesIncludingInherited(Type type, BindingFlags bindingFlags) + { + var dictionary = new Dictionary(StringComparer.Ordinal); + + var currentType = type; + while (currentType != null) + { + var properties = currentType.GetProperties(bindingFlags | BindingFlags.DeclaredOnly); + foreach (var property in properties) + { + if (!dictionary.TryGetValue(property.Name, out var others)) + { + dictionary.Add(property.Name, property); + } + else if (!IsInheritedProperty(property, others)) + { + List many; + if (others is PropertyInfo single) + { + many = new List { single }; + dictionary[property.Name] = many; + } + else + { + many = (List)others; + } + many.Add(property); + } + } + + currentType = currentType.BaseType; + } + + foreach (var item in dictionary) + { + if (item.Value is PropertyInfo property) + { + yield return property; + continue; + } + + var list = (List)item.Value; + var count = list.Count; + for (var i = 0; i < count; i++) + { + yield return list[i]; + } + } + } + + private static bool IsInheritedProperty(PropertyInfo property, object others) + { + if (others is PropertyInfo single) + { + return single.GetMethod?.GetBaseDefinition() == property.GetMethod?.GetBaseDefinition(); + } + + var many = (List)others; + foreach (var other in CollectionsMarshal.AsSpan(many)) + { + if (other.GetMethod?.GetBaseDefinition() == property.GetMethod?.GetBaseDefinition()) + { + return true; + } + } + + return false; + } + [Obsolete("ContentUrl(Alias alias, int fileId) is deprecated. Use FileUrl(Alias alias, int fileId) instead.", false)] public static string ContentUrl(Alias alias, int fileId) {