diff --git a/.gitignore b/.gitignore index 69863764..04ce32b7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ Oqtane.Server/Packages Oqtane.Server/wwwroot/Content Oqtane.Server/wwwroot/Packages/*.log +Oqtane.Server/wwwroot/_content/* +!Oqtane.Server/wwwroot/_content/Placeholder.txt + Oqtane.Server/wwwroot/Modules/* !Oqtane.Server/wwwroot/Modules/Oqtane.Modules.* !Oqtane.Server/wwwroot/Modules/Templates diff --git a/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs b/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs index 8facf91f..457af666 100644 --- a/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs +++ b/Oqtane.Client/Extensions/OqtaneServiceCollectionExtensions.cs @@ -51,6 +51,7 @@ namespace Microsoft.Extensions.DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // providers services.AddScoped(); diff --git a/Oqtane.Client/Installer/Installer.razor b/Oqtane.Client/Installer/Installer.razor index 94f9cc39..c0187971 100644 --- a/Oqtane.Client/Installer/Installer.razor +++ b/Oqtane.Client/Installer/Installer.razor @@ -15,7 +15,7 @@
-
@SharedLocalizer["Version"] @Constants.Version (.NET 8)
+
@SharedLocalizer["Version"] @Constants.Version (.NET 9)

@@ -156,129 +156,130 @@ private List _templates; private string _template = Constants.DefaultSiteTemplate; private bool _register = true; - private string _message = string.Empty; - private string _loadingDisplay = "display: none;"; + private string _message = string.Empty; + private string _loadingDisplay = "display: none;"; - protected override async Task OnInitializedAsync() - { + protected override async Task OnInitializedAsync() + { // include CSS var content = $""; SiteState.AppendHeadContent(content); _togglePassword = SharedLocalizer["ShowPassword"]; - _toggleConfirmPassword = SharedLocalizer["ShowPassword"]; + _toggleConfirmPassword = SharedLocalizer["ShowPassword"]; - _databases = await DatabaseService.GetDatabasesAsync(); - if (_databases.Exists(item => item.IsDefault)) - { - _databaseName = _databases.Find(item => item.IsDefault).Name; - } - else - { - _databaseName = "LocalDB"; - } - LoadDatabaseConfigComponent(); + _databases = await DatabaseService.GetDatabasesAsync(); + if (_databases.Exists(item => item.IsDefault)) + { + _databaseName = _databases.Find(item => item.IsDefault).Name; + } + else + { + _databaseName = "LocalDB"; + } + LoadDatabaseConfigComponent(); _templates = await SiteTemplateService.GetSiteTemplatesAsync(); } - private void DatabaseChanged(ChangeEventArgs eventArgs) - { - try - { - _databaseName = (string)eventArgs.Value; - _showConnectionString = false; - LoadDatabaseConfigComponent(); - } - catch - { - _message = Localizer["Error.DbConfig.Load"]; - } - } + private void DatabaseChanged(ChangeEventArgs eventArgs) + { + try + { + _databaseName = (string)eventArgs.Value; + _showConnectionString = false; + LoadDatabaseConfigComponent(); + } + catch + { + _message = Localizer["Error.DbConfig.Load"]; + } + } - private void LoadDatabaseConfigComponent() - { - var database = _databases.SingleOrDefault(d => d.Name == _databaseName); - if (database != null) - { - _databaseConfigType = Type.GetType(database.ControlType); - DatabaseConfigComponent = builder => - { - builder.OpenComponent(0, _databaseConfigType); - builder.AddComponentReferenceCapture(1, inst => { _databaseConfig = Convert.ChangeType(inst, _databaseConfigType); }); - builder.CloseComponent(); - }; - } - } + private void LoadDatabaseConfigComponent() + { + var database = _databases.SingleOrDefault(d => d.Name == _databaseName); + if (database != null) + { + _databaseConfigType = Type.GetType(database.ControlType); + DatabaseConfigComponent = builder => + { + builder.OpenComponent(0, _databaseConfigType); + builder.AddComponentReferenceCapture(1, inst => { _databaseConfig = Convert.ChangeType(inst, _databaseConfigType); }); + builder.CloseComponent(); + }; + } + } - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - { + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { // include JavaScript - var interop = new Interop(JSRuntime); + var interop = new Interop(JSRuntime); await interop.IncludeScript("", Constants.BootstrapScriptUrl, Constants.BootstrapScriptIntegrity, "anonymous", "", "head"); - } - } + } + } - private async Task Install() - { - var connectionString = String.Empty; - if (_showConnectionString) - { - connectionString = _connectionString; - } - else - { - if (_databaseConfig is IDatabaseConfigControl databaseConfigControl) - { - connectionString = databaseConfigControl.GetConnectionString(); - } - } + private async Task Install() + { + var connectionString = String.Empty; + if (_showConnectionString) + { + connectionString = _connectionString; + } + else + { + if (_databaseConfig is IDatabaseConfigControl databaseConfigControl) + { + connectionString = databaseConfigControl.GetConnectionString(); + } + } - if (connectionString != "" && !string.IsNullOrEmpty(_hostUsername) && !string.IsNullOrEmpty(_hostPassword) && _hostPassword == _confirmPassword && !string.IsNullOrEmpty(_hostEmail) && _hostEmail.Contains("@")) - { - if (await UserService.ValidatePasswordAsync(_hostPassword)) - { - _loadingDisplay = ""; - StateHasChanged(); + if (connectionString != "" && !string.IsNullOrEmpty(_hostUsername) && !string.IsNullOrEmpty(_hostPassword) && _hostPassword == _confirmPassword && !string.IsNullOrEmpty(_hostEmail) && _hostEmail.Contains("@")) + { + var result = await UserService.ValidateUserAsync(_hostUsername, _hostEmail, _hostPassword); + if (result.Succeeded) + { + _loadingDisplay = ""; + StateHasChanged(); - Uri uri = new Uri(NavigationManager.Uri); + Uri uri = new Uri(NavigationManager.Uri); - var database = _databases.SingleOrDefault(d => d.Name == _databaseName); + var database = _databases.SingleOrDefault(d => d.Name == _databaseName); - var config = new InstallConfig - { - DatabaseType = database.DBType, - ConnectionString = connectionString, - Aliases = uri.Authority, - HostUsername = _hostUsername, - HostPassword = _hostPassword, - HostEmail = _hostEmail, - HostName = _hostUsername, - TenantName = TenantNames.Master, - IsNewTenant = true, - SiteName = Constants.DefaultSite, - Register = _register, - SiteTemplate = _template, - RenderMode = RenderModes.Static, - Runtime = Runtimes.Server - }; + var config = new InstallConfig + { + DatabaseType = database.DBType, + ConnectionString = connectionString, + Aliases = uri.Authority, + HostUsername = _hostUsername, + HostPassword = _hostPassword, + HostEmail = _hostEmail, + HostName = _hostUsername, + TenantName = TenantNames.Master, + IsNewTenant = true, + SiteName = Constants.DefaultSite, + Register = _register, + SiteTemplate = _template, + RenderMode = RenderModes.Static, + Runtime = Runtimes.Server + }; - var installation = await InstallationService.Install(config); - if (installation.Success) - { - NavigationManager.NavigateTo(uri.Scheme + "://" + uri.Authority, true); - } - else - { - _message = installation.Message; - _loadingDisplay = "display: none;"; - } - } - else - { - _message = Localizer["Message.Password.Invalid"]; + var installation = await InstallationService.Install(config); + if (installation.Success) + { + NavigationManager.NavigateTo(uri.Scheme + "://" + uri.Authority, true); + } + else + { + _message = installation.Message; + _loadingDisplay = "display: none;"; + } + } + else + { + _message = string.Join("
", result.Errors.Select(i => !string.IsNullOrEmpty(i.Value) ? i.Value : Localizer[i.Key])); } } else diff --git a/Oqtane.Client/Modules/Admin/Languages/Add.razor b/Oqtane.Client/Modules/Admin/Languages/Add.razor index 23af68e5..fedc045b 100644 --- a/Oqtane.Client/Modules/Admin/Languages/Add.razor +++ b/Oqtane.Client/Modules/Admin/Languages/Add.razor @@ -1,7 +1,6 @@ @namespace Oqtane.Modules.Admin.Languages @inherits ModuleBase @using System.Globalization -@using Microsoft.AspNetCore.Localization @inject NavigationManager NavigationManager @inject ILocalizationService LocalizationService @inject ILanguageService LanguageService @@ -94,7 +93,6 @@ else var language = new Language { SiteId = PageState.Page.SiteId, - Name = CultureInfo.GetCultureInfo(_code).DisplayName, Code = _code, IsDefault = (_default == null ? false : Boolean.Parse(_default)) }; @@ -130,7 +128,7 @@ else { var interop = new Interop(JSRuntime); var localizationCookieValue = CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)); - await interop.SetCookie(CookieRequestCultureProvider.DefaultCookieName, localizationCookieValue, 360); + await interop.SetCookie(CookieRequestCultureProvider.DefaultCookieName, localizationCookieValue, 360, true, "Lax"); } } diff --git a/Oqtane.Client/Modules/Admin/Languages/Edit.razor b/Oqtane.Client/Modules/Admin/Languages/Edit.razor index 5929edd9..3a4060ac 100644 --- a/Oqtane.Client/Modules/Admin/Languages/Edit.razor +++ b/Oqtane.Client/Modules/Admin/Languages/Edit.razor @@ -1,7 +1,6 @@ @namespace Oqtane.Modules.Admin.Languages @inherits ModuleBase @using System.Globalization -@using Microsoft.AspNetCore.Localization @inject NavigationManager NavigationManager @inject ILocalizationService LocalizationService @inject ILanguageService LanguageService @@ -103,7 +102,7 @@ else { var interop = new Interop(JSRuntime); var localizationCookieValue = CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)); - await interop.SetCookie(CookieRequestCultureProvider.DefaultCookieName, localizationCookieValue, 360); + await interop.SetCookie(CookieRequestCultureProvider.DefaultCookieName, localizationCookieValue, 360, true, "Lax"); } } diff --git a/Oqtane.Client/Modules/Admin/Login/Index.razor b/Oqtane.Client/Modules/Admin/Login/Index.razor index 23d7360f..f3f22c9c 100644 --- a/Oqtane.Client/Modules/Admin/Login/Index.razor +++ b/Oqtane.Client/Modules/Admin/Login/Index.razor @@ -8,74 +8,77 @@ @inject IStringLocalizer Localizer @inject IStringLocalizer SharedLocalizer - - - ... - - - - - - @if (!twofactor) - { -
-
- @if (_allowexternallogin) - { - -

- } - @if (_allowsitelogin) - { -
- - -
-
- -
- +@if (PageState.User != null) +{ + +} +else +{ + @if (!twofactor) + { + +
+ @if (_allowexternallogin) + { + +
+ +
+ } + @if (_allowsitelogin) + { +
+ + +
+
+ +
+ -
-
-
- @if (!_alwaysremember) - { -
- - -
- } -
- - -

- - @if (PageState.Site.AllowRegistration) +
+
+
+ @if (!_alwaysremember) { -

- @Localizer["Register"] +
+ + +
} +
+ + +
+ +
+ + @if (PageState.Site.AllowRegistration) + { +
+ +
+ @Localizer["Register"] } -
- - } - else - { -
-
-
- - -
-
- - -
-
- } - - + } +
+ + } + else + { +
+
+
+ + +
+
+ + +
+
+ } +} @code { private bool _allowsitelogin = true; @@ -204,9 +207,9 @@ user = await UserService.VerifyTwoFactorAsync(user, _code); } - if (user.IsAuthenticated) + if (user != null && user.IsAuthenticated) { - await logger.LogInformation(LogFunction.Security, "Login Successful For Username {Username}", _username); + await logger.LogInformation(LogFunction.Security, "Login Successful For {Username} From IP Address {IPAddress}", _username, SiteState.RemoteIPAddress); // return url is not specified if user navigated directly to login page var returnurl = (!string.IsNullOrEmpty(PageState.ReturnUrl)) ? PageState.ReturnUrl : PageState.Alias.Path; @@ -228,7 +231,7 @@ } else { - if (SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:TwoFactor", "false") == "required" || user.TwoFactorRequired) + if (SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:TwoFactor", "false") == "required" || (user != null && user.TwoFactorRequired)) { twofactor = true; validated = false; @@ -239,12 +242,12 @@ if (!twofactor) { await logger.LogInformation(LogFunction.Security, "Login Failed For Username {Username}", _username); - AddModuleMessage(Localizer["Error.Login.Fail"], MessageType.Error); + AddModuleMessage(Localizer["Error.Login.Fail"], MessageType.Error); } else { await logger.LogInformation(LogFunction.Security, "Two Factor Verification Failed For Username {Username}", _username); - AddModuleMessage(Localizer["Error.TwoFactor.Fail"], MessageType.Error); + AddModuleMessage(Localizer["Error.TwoFactor.Fail"], MessageType.Error); } } } diff --git a/Oqtane.Client/Modules/Admin/ModuleDefinitions/Create.razor b/Oqtane.Client/Modules/Admin/ModuleDefinitions/Create.razor index f673ca10..60241e2a 100644 --- a/Oqtane.Client/Modules/Admin/ModuleDefinitions/Create.razor +++ b/Oqtane.Client/Modules/Admin/ModuleDefinitions/Create.razor @@ -27,7 +27,7 @@
- +
@@ -118,6 +118,7 @@ { if (IsValid(_owner) && IsValid(_module) && _owner != _module && _template != "-") { + if (string.IsNullOrEmpty(_description)) _description = _module; if (IsValidXML(_description)) { var template = _templates.FirstOrDefault(item => item.Name == _template); diff --git a/Oqtane.Client/Modules/Admin/ModuleDefinitions/Edit.razor b/Oqtane.Client/Modules/Admin/ModuleDefinitions/Edit.razor index ffa2f1e3..4693891e 100644 --- a/Oqtane.Client/Modules/Admin/ModuleDefinitions/Edit.razor +++ b/Oqtane.Client/Modules/Admin/ModuleDefinitions/Edit.razor @@ -1,7 +1,6 @@ @namespace Oqtane.Modules.Admin.ModuleDefinitions @inherits ModuleBase @using System.Globalization -@using Microsoft.AspNetCore.Localization @inject IModuleDefinitionService ModuleDefinitionService @inject IPackageService PackageService @inject ILanguageService LanguageService diff --git a/Oqtane.Client/Modules/Admin/Modules/Export.razor b/Oqtane.Client/Modules/Admin/Modules/Export.razor index 45a22e18..d2e90193 100644 --- a/Oqtane.Client/Modules/Admin/Modules/Export.razor +++ b/Oqtane.Client/Modules/Admin/Modules/Export.razor @@ -15,7 +15,7 @@
-@SharedLocalizer["Cancel"] +@SharedLocalizer["Cancel"] @code { private string _content = string.Empty; diff --git a/Oqtane.Client/Modules/Admin/Modules/Import.razor b/Oqtane.Client/Modules/Admin/Modules/Import.razor index 961157eb..04b2557a 100644 --- a/Oqtane.Client/Modules/Admin/Modules/Import.razor +++ b/Oqtane.Client/Modules/Admin/Modules/Import.razor @@ -17,7 +17,7 @@ - @SharedLocalizer["Cancel"] + @SharedLocalizer["Cancel"] @code { diff --git a/Oqtane.Client/Modules/Admin/Modules/Settings.razor b/Oqtane.Client/Modules/Admin/Modules/Settings.razor index e1183a82..d2ab823e 100644 --- a/Oqtane.Client/Modules/Admin/Modules/Settings.razor +++ b/Oqtane.Client/Modules/Admin/Modules/Settings.razor @@ -167,7 +167,6 @@ { SetModuleTitle(Localizer["ModuleSettings.Title"]); - _module = ModuleState.ModuleDefinition.Name; _title = ModuleState.Title; _moduleSettingsTitle = Localizer["ModuleSettings.Heading"]; _pane = ModuleState.Pane; @@ -186,6 +185,7 @@ if (ModuleState.ModuleDefinition != null) { + _module = ModuleState.ModuleDefinition.Name; _permissionNames = ModuleState.ModuleDefinition?.PermissionNames; if (!string.IsNullOrEmpty(ModuleState.ModuleDefinition.SettingsType)) diff --git a/Oqtane.Client/Modules/Admin/Pages/Add.razor b/Oqtane.Client/Modules/Admin/Pages/Add.razor index cc78ffab..ac7f064c 100644 --- a/Oqtane.Client/Modules/Admin/Pages/Add.razor +++ b/Oqtane.Client/Modules/Admin/Pages/Add.razor @@ -156,7 +156,14 @@ diff --git a/Oqtane.Client/Modules/Admin/Pages/Edit.razor b/Oqtane.Client/Modules/Admin/Pages/Edit.razor index 4be40d32..0d408c03 100644 --- a/Oqtane.Client/Modules/Admin/Pages/Edit.razor +++ b/Oqtane.Client/Modules/Admin/Pages/Edit.razor @@ -172,7 +172,14 @@ @@ -262,7 +269,14 @@ diff --git a/Oqtane.Client/Modules/Admin/RecycleBin/Index.razor b/Oqtane.Client/Modules/Admin/RecycleBin/Index.razor index 91cf3669..4e4e2607 100644 --- a/Oqtane.Client/Modules/Admin/RecycleBin/Index.razor +++ b/Oqtane.Client/Modules/Admin/RecycleBin/Index.razor @@ -22,7 +22,7 @@ else } else { - +
    @@ -50,7 +50,7 @@ else } else { - +
    diff --git a/Oqtane.Client/Modules/Admin/Register/Index.razor b/Oqtane.Client/Modules/Admin/Register/Index.razor index 1239a512..88ccdd56 100644 --- a/Oqtane.Client/Modules/Admin/Register/Index.razor +++ b/Oqtane.Client/Modules/Admin/Register/Index.razor @@ -11,65 +11,64 @@ { if (!_userCreated) { - - - ... - - - - - - -
-
-
- -
- -
+ if (PageState.User != null) + { + + } + else + { + + +
+
+ +
+
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
- -
-
-
- -
- +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + + @if (_allowsitelogin) + {
- - - @if (_allowsitelogin) - { -

- @Localizer["Login"] - } - - - + +
+ @Localizer["Login"] + } + + } } } else diff --git a/Oqtane.Client/Modules/Admin/Search/Index.razor b/Oqtane.Client/Modules/Admin/Search/Index.razor index 8a32ff22..ee47feea 100644 --- a/Oqtane.Client/Modules/Admin/Search/Index.razor +++ b/Oqtane.Client/Modules/Admin/Search/Index.razor @@ -63,7 +63,7 @@ private string _enabled = "True"; private string _lastIndexedOn = ""; private string _ignorePages = ""; - private string _ignoreEntities = ""; + private string _ignoreEntities = "File"; private string _minimumWordLength = "3"; private string _ignoreWords = "the,be,to,of,and,a,i,in,that,have,it,for,not,on,with,he,as,you,do,at,this,but,his,by,from,they,we,say,her,she,or,an,will,my,one,all,would,there,their,what,so,up,out,if,about,who,get,which,go,me,when,make,can,like,time,no,just,him,know,take,people,into,year,your,good,some,could,them,see,other,than,then,now,look,only,come,its,over,think,also,back,after,use,two,how,our,work,first,well,way,even,new,want,because,any,these,give,day,most,us"; @@ -85,7 +85,7 @@ { var settings = await SettingService.GetSiteSettingsAsync(PageState.Site.SiteId); settings = SettingService.SetSetting(settings, "Search_SearchProvider", _searchProvider); - settings = SettingService.SetSetting(settings, "Search_Enabled", _enabled, true); + settings = SettingService.SetSetting(settings, "Search_Enabled", _enabled); settings = SettingService.SetSetting(settings, "Search_LastIndexedOn", _lastIndexedOn, true); settings = SettingService.SetSetting(settings, "Search_IgnorePages", _ignorePages, true); settings = SettingService.SetSetting(settings, "Search_IgnoreEntities", _ignoreEntities, true); @@ -106,9 +106,7 @@ try { _lastIndexedOn = DateTime.MinValue.ToString(); - var settings = await SettingService.GetSiteSettingsAsync(PageState.Site.SiteId); - settings = SettingService.SetSetting(settings, "Search_LastIndexedOn", _lastIndexedOn, true); - await SettingService.UpdateSiteSettingsAsync(settings, PageState.Site.SiteId); + await Save(); AddModuleMessage(Localizer["Message.Reindex"], MessageType.Success); } catch (Exception ex) diff --git a/Oqtane.Client/Modules/Admin/Site/Index.razor b/Oqtane.Client/Modules/Admin/Site/Index.razor index b7b96faa..ff94804a 100644 --- a/Oqtane.Client/Modules/Admin/Site/Index.razor +++ b/Oqtane.Client/Modules/Admin/Site/Index.razor @@ -207,14 +207,14 @@
- +
- +
@@ -376,7 +376,7 @@
- +
@@ -388,7 +388,7 @@
- +
@@ -571,7 +571,7 @@ if (tenant != null) { _tenant = tenant.Name; - _database = _databases.Find(item => item.DBType == tenant.DBType)?.Name; + _database = _databases.Find(item => item.DBType == tenant.DBType && item.Name != "LocalDB")?.Name; _connectionstring = tenant.DBConnectionString; } } diff --git a/Oqtane.Client/Modules/Admin/Sites/Add.razor b/Oqtane.Client/Modules/Admin/Sites/Add.razor index 300aafcb..47f19b04 100644 --- a/Oqtane.Client/Modules/Admin/Sites/Add.razor +++ b/Oqtane.Client/Modules/Admin/Sites/Add.razor @@ -109,7 +109,7 @@ else
- +
diff --git a/Oqtane.Client/Modules/Admin/Sql/Index.razor b/Oqtane.Client/Modules/Admin/Sql/Index.razor index 6d0dee92..476ebd1e 100644 --- a/Oqtane.Client/Modules/Admin/Sql/Index.razor +++ b/Oqtane.Client/Modules/Admin/Sql/Index.razor @@ -83,24 +83,15 @@ else { @if (_connection != "-") { -
- -
- @if (_databases != null) - { - - } -
-
@if (!string.IsNullOrEmpty(_tenant)) { -
+
+ +
+ +
+
+
@@ -204,12 +195,12 @@ else { _connectionstring = _connections[_connection].ToString(); _tenant = ""; - _databasetype = "-"; + _databasetype = ""; var tenant = _tenants.FirstOrDefault(item => item.DBConnectionString == _connection); if (tenant != null) { _tenant = tenant.Name; - _databasetype = _databases.FirstOrDefault(item => item.DBType == tenant.DBType).Name; + _databasetype = _databases.FirstOrDefault(item => item.DBType == tenant.DBType && item.Name != "LocalDB").Name; } } else diff --git a/Oqtane.Client/Modules/Admin/Upgrade/Index.razor b/Oqtane.Client/Modules/Admin/Upgrade/Index.razor index 2c91688e..c43ae566 100644 --- a/Oqtane.Client/Modules/Admin/Upgrade/Index.razor +++ b/Oqtane.Client/Modules/Admin/Upgrade/Index.razor @@ -54,6 +54,8 @@ } else { + AddModuleMessage(Localizer["Disclaimer.Text"], MessageType.Warning); + List packages = await PackageService.GetPackagesAsync("framework", "", "", ""); if (packages != null) { @@ -97,13 +99,16 @@ { try { + ShowProgressIndicator(); await PackageService.DownloadPackageAsync(packageid, version); await PackageService.DownloadPackageAsync(Constants.UpdaterPackageId, version); + HideProgressIndicator(); AddModuleMessage(Localizer["Success.Framework.Download"], MessageType.Success); } catch (Exception ex) { await logger.LogError(ex, "Error Downloading Framework Package {Error}", ex.Message); + HideProgressIndicator(); AddModuleMessage(Localizer["Error.Framework.Download"], MessageType.Error); } } diff --git a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor index c67f3305..91a31585 100644 --- a/Oqtane.Client/Modules/Admin/UserProfile/Index.razor +++ b/Oqtane.Client/Modules/Admin/UserProfile/Index.razor @@ -9,6 +9,8 @@ @inject INotificationService NotificationService @inject IFileService FileService @inject IFolderService FolderService +@inject IJSRuntime jsRuntime +@inject IServiceProvider ServiceProvider @inject IStringLocalizer Localizer @inject IStringLocalizer SharedLocalizer @@ -84,6 +86,7 @@
+
@@ -518,6 +521,32 @@ } } + private async Task Logout() + { + await logger.LogInformation("User Logout Everywhere For Username {Username}", PageState.User?.Username); + + var url = NavigateUrl(""); // home page + + if (PageState.Runtime == Shared.Runtime.Hybrid) + { + if (PageState.User != null) + { + // hybrid apps utilize an interactive logout + await UserService.LogoutUserEverywhereAsync(PageState.User); + var authstateprovider = (IdentityAuthenticationStateProvider)ServiceProvider.GetService(typeof(IdentityAuthenticationStateProvider)); + authstateprovider.NotifyAuthenticationChanged(); + NavigationManager.NavigateTo(url, true); + } + } + else + { + // post to the Logout page to complete the logout process + var fields = new { __RequestVerificationToken = SiteState.AntiForgeryToken, returnurl = url, everywhere = true }; + var interop = new Interop(jsRuntime); + await interop.SubmitForm(Utilities.TenantUrl(PageState.Alias, "/pages/logout/"), fields); + } + } + private bool ValidateProfiles() { foreach (Profile profile in profiles) diff --git a/Oqtane.Client/Modules/Admin/Users/Index.razor b/Oqtane.Client/Modules/Admin/Users/Index.razor index a04be4c3..a5211c55 100644 --- a/Oqtane.Client/Modules/Admin/Users/Index.razor +++ b/Oqtane.Client/Modules/Admin/Users/Index.razor @@ -182,13 +182,31 @@ else
-
+
+ +
+
+ + @if (!string.IsNullOrEmpty(_providerurl)) + { + @Localizer["Info"] + } +
+ +
+
+
@@ -333,12 +351,29 @@ else
- +
-
+
+ +
+ +
+
+
+ +
+
+ +
+
+
+
@@ -435,6 +470,8 @@ else private string _maximumfailures; private string _lockoutduration; + private string _provider; + private string _providerurl; private string _providertype; private string _providername; private string _authority; @@ -457,6 +494,8 @@ else private string _nameclaimtype; private string _emailclaimtype; private string _roleclaimtype; + private string _roleclaimmappings; + private string _synchronizeroles; private string _profileclaimtypes; private string _domainfilter; private string _createusers; @@ -500,31 +539,7 @@ else _maximumfailures = SettingService.GetSetting(settings, "IdentityOptions:Lockout:MaxFailedAccessAttempts", "5"); _lockoutduration = TimeSpan.Parse(SettingService.GetSetting(settings, "IdentityOptions:Lockout:DefaultLockoutTimeSpan", "00:05:00")).TotalMinutes.ToString(); - _providertype = SettingService.GetSetting(settings, "ExternalLogin:ProviderType", ""); - _providername = SettingService.GetSetting(settings, "ExternalLogin:ProviderName", ""); - _authority = SettingService.GetSetting(settings, "ExternalLogin:Authority", ""); - _metadataurl = SettingService.GetSetting(settings, "ExternalLogin:MetadataUrl", ""); - _authorizationurl = SettingService.GetSetting(settings, "ExternalLogin:AuthorizationUrl", ""); - _tokenurl = SettingService.GetSetting(settings, "ExternalLogin:TokenUrl", ""); - _userinfourl = SettingService.GetSetting(settings, "ExternalLogin:UserInfoUrl", ""); - _clientid = SettingService.GetSetting(settings, "ExternalLogin:ClientId", ""); - _clientsecret = SettingService.GetSetting(settings, "ExternalLogin:ClientSecret", ""); - _toggleclientsecret = SharedLocalizer["ShowPassword"]; - _authresponsetype = SettingService.GetSetting(settings, "ExternalLogin:AuthResponseType", "code"); - _scopes = SettingService.GetSetting(settings, "ExternalLogin:Scopes", ""); - _parameters = SettingService.GetSetting(settings, "ExternalLogin:Parameters", ""); - _pkce = SettingService.GetSetting(settings, "ExternalLogin:PKCE", "false"); - _redirecturl = PageState.Uri.Scheme + "://" + PageState.Alias.Name + "/signin-" + _providertype; - _reviewclaims = SettingService.GetSetting(settings, "ExternalLogin:ReviewClaims", "false"); - _externalloginurl = Utilities.TenantUrl(PageState.Alias, "/pages/external"); - _identifierclaimtype = SettingService.GetSetting(settings, "ExternalLogin:IdentifierClaimType", "sub"); - _nameclaimtype = SettingService.GetSetting(settings, "ExternalLogin:NameClaimType", "name"); - _emailclaimtype = SettingService.GetSetting(settings, "ExternalLogin:EmailClaimType", "email"); - _roleclaimtype = SettingService.GetSetting(settings, "ExternalLogin:RoleClaimType", ""); - _profileclaimtypes = SettingService.GetSetting(settings, "ExternalLogin:ProfileClaimTypes", ""); - _domainfilter = SettingService.GetSetting(settings, "ExternalLogin:DomainFilter", ""); - _createusers = SettingService.GetSetting(settings, "ExternalLogin:CreateUsers", "true"); - _verifyusers = SettingService.GetSetting(settings, "ExternalLogin:VerifyUsers", "true"); + LoadExternalLoginSettings(settings); _secret = SettingService.GetSetting(settings, "JwtOptions:Secret", ""); _togglesecret = SharedLocalizer["ShowPassword"]; @@ -534,6 +549,39 @@ else } } + private void LoadExternalLoginSettings(Dictionary settings) + { + _provider = SettingService.GetSetting(settings, "ExternalLogin:Provider", ""); + _providerurl = SettingService.GetSetting(settings, "ExternalLogin:ProviderUrl", ""); + _providertype = SettingService.GetSetting(settings, "ExternalLogin:ProviderType", ""); + _providername = SettingService.GetSetting(settings, "ExternalLogin:ProviderName", ""); + _authority = SettingService.GetSetting(settings, "ExternalLogin:Authority", ""); + _metadataurl = SettingService.GetSetting(settings, "ExternalLogin:MetadataUrl", ""); + _authorizationurl = SettingService.GetSetting(settings, "ExternalLogin:AuthorizationUrl", ""); + _tokenurl = SettingService.GetSetting(settings, "ExternalLogin:TokenUrl", ""); + _userinfourl = SettingService.GetSetting(settings, "ExternalLogin:UserInfoUrl", ""); + _clientid = SettingService.GetSetting(settings, "ExternalLogin:ClientId", ""); + _clientsecret = SettingService.GetSetting(settings, "ExternalLogin:ClientSecret", ""); + _toggleclientsecret = SharedLocalizer["ShowPassword"]; + _authresponsetype = SettingService.GetSetting(settings, "ExternalLogin:AuthResponseType", "code"); + _scopes = SettingService.GetSetting(settings, "ExternalLogin:Scopes", ""); + _parameters = SettingService.GetSetting(settings, "ExternalLogin:Parameters", ""); + _pkce = SettingService.GetSetting(settings, "ExternalLogin:PKCE", "false"); + _redirecturl = PageState.Uri.Scheme + "://" + PageState.Alias.Name + "/signin-" + _providertype; + _reviewclaims = SettingService.GetSetting(settings, "ExternalLogin:ReviewClaims", "false"); + _externalloginurl = Utilities.TenantUrl(PageState.Alias, "/pages/external"); + _identifierclaimtype = SettingService.GetSetting(settings, "ExternalLogin:IdentifierClaimType", "sub"); + _nameclaimtype = SettingService.GetSetting(settings, "ExternalLogin:NameClaimType", "name"); + _emailclaimtype = SettingService.GetSetting(settings, "ExternalLogin:EmailClaimType", "email"); + _roleclaimtype = SettingService.GetSetting(settings, "ExternalLogin:RoleClaimType", ""); + _roleclaimmappings = SettingService.GetSetting(settings, "ExternalLogin:RoleClaimMappings", ""); + _synchronizeroles = SettingService.GetSetting(settings, "ExternalLogin:SynchronizeRoles", "false"); + _profileclaimtypes = SettingService.GetSetting(settings, "ExternalLogin:ProfileClaimTypes", ""); + _domainfilter = SettingService.GetSetting(settings, "ExternalLogin:DomainFilter", ""); + _createusers = SettingService.GetSetting(settings, "ExternalLogin:CreateUsers", "true"); + _verifyusers = SettingService.GetSetting(settings, "ExternalLogin:VerifyUsers", "true"); + } + private async Task LoadUsersAsync(bool load) { if (load) @@ -546,103 +594,121 @@ else users = users.OrderBy(u => u.User.DisplayName).ToList(); } } - } + } - private async Task DeleteUser(UserRole UserRole) - { - try - { - var user = await UserService.GetUserAsync(UserRole.UserId, PageState.Site.SiteId); - if (user != null) - { - await UserService.DeleteUserAsync(user.UserId, PageState.Site.SiteId); - await logger.LogInformation("User Deleted {User}", UserRole.User); - await LoadUsersAsync(true); - StateHasChanged(); - } - } - catch (Exception ex) - { - await logger.LogError(ex, "Error Deleting User {User} {Error}", UserRole.User, ex.Message); - AddModuleMessage(ex.Message, MessageType.Error); - } - } + private async Task DeleteUser(UserRole UserRole) + { + try + { + var user = await UserService.GetUserAsync(UserRole.UserId, PageState.Site.SiteId); + if (user != null) + { + await UserService.DeleteUserAsync(user.UserId, PageState.Site.SiteId); + await logger.LogInformation("User Deleted {User}", UserRole.User); + await LoadUsersAsync(true); + StateHasChanged(); + } + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Deleting User {User} {Error}", UserRole.User, ex.Message); + AddModuleMessage(ex.Message, MessageType.Error); + } + } - private async Task SaveSiteSettings() - { - try - { - var site = PageState.Site; - site.AllowRegistration = bool.Parse(_allowregistration); - await SiteService.UpdateSiteAsync(site); + private async Task SaveSiteSettings() + { + try + { + var site = PageState.Site; + site.AllowRegistration = bool.Parse(_allowregistration); + await SiteService.UpdateSiteAsync(site); - var settings = await SettingService.GetSiteSettingsAsync(site.SiteId); - settings = SettingService.SetSetting(settings, "LoginOptions:AllowSiteLogin", _allowsitelogin, false); + var settings = await SettingService.GetSiteSettingsAsync(site.SiteId); + settings = SettingService.SetSetting(settings, "LoginOptions:AllowSiteLogin", _allowsitelogin, false); - if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) - { - settings = SettingService.SetSetting(settings, "LoginOptions:TwoFactor", _twofactor, false); - settings = SettingService.SetSetting(settings, "LoginOptions:CookieName", _cookiename, true); - settings = SettingService.SetSetting(settings, "LoginOptions:CookieExpiration", _cookieexpiration, true); - settings = SettingService.SetSetting(settings, "LoginOptions:AlwaysRemember", _alwaysremember, false); + if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) + { + settings = SettingService.SetSetting(settings, "LoginOptions:TwoFactor", _twofactor, false); + settings = SettingService.SetSetting(settings, "LoginOptions:CookieName", _cookiename, true); + settings = SettingService.SetSetting(settings, "LoginOptions:CookieExpiration", _cookieexpiration, true); + settings = SettingService.SetSetting(settings, "LoginOptions:AlwaysRemember", _alwaysremember, false); - settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequiredLength", _minimumlength, true); - settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequiredUniqueChars", _uniquecharacters, true); - settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequireDigit", _requiredigit, true); - settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequireUppercase", _requireupper, true); - settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequireLowercase", _requirelower, true); - settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequireNonAlphanumeric", _requirepunctuation, true); + settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequiredLength", _minimumlength, true); + settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequiredUniqueChars", _uniquecharacters, true); + settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequireDigit", _requiredigit, true); + settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequireUppercase", _requireupper, true); + settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequireLowercase", _requirelower, true); + settings = SettingService.SetSetting(settings, "IdentityOptions:Password:RequireNonAlphanumeric", _requirepunctuation, true); - settings = SettingService.SetSetting(settings, "IdentityOptions:Lockout:MaxFailedAccessAttempts", _maximumfailures, true); - settings = SettingService.SetSetting(settings, "IdentityOptions:Lockout:DefaultLockoutTimeSpan", TimeSpan.FromMinutes(Convert.ToInt64(_lockoutduration)).ToString(), true); + settings = SettingService.SetSetting(settings, "IdentityOptions:Lockout:MaxFailedAccessAttempts", _maximumfailures, true); + settings = SettingService.SetSetting(settings, "IdentityOptions:Lockout:DefaultLockoutTimeSpan", TimeSpan.FromMinutes(Convert.ToInt64(_lockoutduration)).ToString(), true); - settings = SettingService.SetSetting(settings, "ExternalLogin:ProviderType", _providertype, false); - settings = SettingService.SetSetting(settings, "ExternalLogin:ProviderName", _providername, false); - settings = SettingService.SetSetting(settings, "ExternalLogin:Authority", _authority, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:MetadataUrl", _metadataurl, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:AuthorizationUrl", _authorizationurl, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:TokenUrl", _tokenurl, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:UserInfoUrl", _userinfourl, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:ClientId", _clientid, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:ClientSecret", _clientsecret, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:Provider", _provider, false); + settings = SettingService.SetSetting(settings, "ExternalLogin:ProviderType", _providertype, false); + settings = SettingService.SetSetting(settings, "ExternalLogin:ProviderName", _providername, false); + settings = SettingService.SetSetting(settings, "ExternalLogin:Authority", _authority, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:MetadataUrl", _metadataurl, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:AuthorizationUrl", _authorizationurl, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:TokenUrl", _tokenurl, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:UserInfoUrl", _userinfourl, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:ClientId", _clientid, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:ClientSecret", _clientsecret, true); settings = SettingService.SetSetting(settings, "ExternalLogin:AuthResponseType", _authresponsetype, true); settings = SettingService.SetSetting(settings, "ExternalLogin:Scopes", _scopes, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:Parameters", _parameters, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:PKCE", _pkce, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:Parameters", _parameters, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:PKCE", _pkce, true); settings = SettingService.SetSetting(settings, "ExternalLogin:ReviewClaims", _reviewclaims, true); settings = SettingService.SetSetting(settings, "ExternalLogin:IdentifierClaimType", _identifierclaimtype, true); settings = SettingService.SetSetting(settings, "ExternalLogin:NameClaimType", _nameclaimtype, true); settings = SettingService.SetSetting(settings, "ExternalLogin:EmailClaimType", _emailclaimtype, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:RoleClaimType", _roleclaimtype, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:ProfileClaimTypes", _profileclaimtypes, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:DomainFilter", _domainfilter, true); - settings = SettingService.SetSetting(settings, "ExternalLogin:CreateUsers", _createusers, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:RoleClaimType", _roleclaimtype, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:RoleClaimMappings", _roleclaimmappings, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:SynchronizeRoles", _synchronizeroles, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:ProfileClaimTypes", _profileclaimtypes, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:DomainFilter", _domainfilter, true); + settings = SettingService.SetSetting(settings, "ExternalLogin:CreateUsers", _createusers, true); settings = SettingService.SetSetting(settings, "ExternalLogin:VerifyUsers", _verifyusers, true); - settings = SettingService.SetSetting(settings, "JwtOptions:Secret", _secret, true); - settings = SettingService.SetSetting(settings, "JwtOptions:Issuer", _issuer, true); - settings = SettingService.SetSetting(settings, "JwtOptions:Audience", _audience, true); - settings = SettingService.SetSetting(settings, "JwtOptions:Lifetime", _lifetime, true); - } + settings = SettingService.SetSetting(settings, "JwtOptions:Secret", _secret, true); + settings = SettingService.SetSetting(settings, "JwtOptions:Issuer", _issuer, true); + settings = SettingService.SetSetting(settings, "JwtOptions:Audience", _audience, true); + settings = SettingService.SetSetting(settings, "JwtOptions:Lifetime", _lifetime, true); + } - await SettingService.UpdateSiteSettingsAsync(settings, site.SiteId); - await SettingService.ClearSiteSettingsCacheAsync(); + await SettingService.UpdateSiteSettingsAsync(settings, site.SiteId); + await SettingService.ClearSiteSettingsCacheAsync(); - if (!string.IsNullOrEmpty(_secret)) - { - SiteState.AuthorizationToken = await UserService.GetTokenAsync(); - } + if (!string.IsNullOrEmpty(_secret)) + { + SiteState.AuthorizationToken = await UserService.GetTokenAsync(); + } - AddModuleMessage(Localizer["Success.SaveSiteSettings"], MessageType.Success); - } - catch (Exception ex) - { - await logger.LogError(ex, "Error Saving Site Settings {Error}", ex.Message); - AddModuleMessage(Localizer["Error.SaveSiteSettings"], MessageType.Error); - } + AddModuleMessage(Localizer["Success.SaveSiteSettings"], MessageType.Success); + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Saving Site Settings {Error}", ex.Message); + AddModuleMessage(Localizer["Error.SaveSiteSettings"], MessageType.Error); + } + finally + { + await ScrollToPageTop(); + } + } + + private void ProviderChanged(ChangeEventArgs e) + { + _provider = (string)e.Value; + var provider = Shared.ExternalLoginProviders.Providers.FirstOrDefault(item => item.Name == _provider); + if (provider != null) + { + LoadExternalLoginSettings(provider.Settings); + } + StateHasChanged(); } - - private void ProviderTypeChanged(ChangeEventArgs e) + + private void ProviderTypeChanged(ChangeEventArgs e) { _providertype = (string)e.Value; if (string.IsNullOrEmpty(_providername)) diff --git a/Oqtane.Client/Modules/Controls/FileManager.razor b/Oqtane.Client/Modules/Controls/FileManager.razor index b7c603ff..509cef2d 100644 --- a/Oqtane.Client/Modules/Controls/FileManager.razor +++ b/Oqtane.Client/Modules/Controls/FileManager.razor @@ -196,7 +196,7 @@ else { FolderId = -1; - _message = "Folder Path " + Folder + "Does Not Exist"; + _message = "Folder Path " + Folder + " Does Not Exist"; _messagetype = MessageType.Error; } } @@ -226,9 +226,9 @@ } else { - FileId = -1; // file does not exist - _message = "FileId " + FileId.ToString() + "Does Not Exist"; + _message = "FileId " + FileId.ToString() + " Does Not Exist"; _messagetype = MessageType.Error; + FileId = -1; // file does not exist } } @@ -359,12 +359,6 @@ } if (restricted == "") { - if (!ShowProgress) - { - _uploading = true; - StateHasChanged(); - } - try { // upload the files @@ -374,7 +368,21 @@ if (PageState.Runtime == Shared.Runtime.Hybrid) { jwt = await UserService.GetTokenAsync(); + if (string.IsNullOrEmpty(jwt)) + { + await logger.LogInformation("File Upload Failed From .NET MAUI Due To Missing Security Token. Token Options Must Be Set In User Settings."); + _message = "Security Token Not Specified"; + _messagetype = MessageType.Error; + return; + } } + + if (!ShowProgress) + { + _uploading = true; + StateHasChanged(); + } + await interop.UploadFiles(posturl, folder, _guid, SiteState.AntiForgeryToken, jwt); // uploading is asynchronous so we need to poll to determine if uploads are completed @@ -387,7 +395,7 @@ var size = Int64.Parse(uploads[upload].Split(':')[1]); // bytes var megabits = (size / 1048576.0) * 8; // binary conversion - var uploadspeed = 2; // 2 Mbps (3G ranges from 300Kbps to 3Mbps) + var uploadspeed = (PageState.Alias.Name.Contains("localhost")) ? 100 : 3; // 3 Mbps is FCC minimum for broadband upload var uploadtime = (megabits / uploadspeed); // seconds var maxattempts = 5; // polling (minimum timeout duration will be 5 seconds) var sleep = (int)Math.Ceiling(uploadtime / maxattempts) * 1000; // milliseconds diff --git a/Oqtane.Client/Modules/Controls/ModuleMessage.razor b/Oqtane.Client/Modules/Controls/ModuleMessage.razor index c42b40cb..440feff0 100644 --- a/Oqtane.Client/Modules/Controls/ModuleMessage.razor +++ b/Oqtane.Client/Modules/Controls/ModuleMessage.razor @@ -10,13 +10,16 @@ { View Details } - @if (ModuleState.RenderMode == RenderModes.Static) + @if (ModuleState != null) { - - } - else - { - + @if (ModuleState.RenderMode == RenderModes.Static) + { + + } + else + { + + } }
} diff --git a/Oqtane.Client/Modules/Controls/Pager.razor b/Oqtane.Client/Modules/Controls/Pager.razor index ce14eed6..ab1db740 100644 --- a/Oqtane.Client/Modules/Controls/Pager.razor +++ b/Oqtane.Client/Modules/Controls/Pager.razor @@ -452,9 +452,9 @@ _displayPages = int.Parse(DisplayPages); } - if (PageState.QueryString.ContainsKey("page")) + if (PageState.QueryString.ContainsKey("page") && int.TryParse(PageState.QueryString["page"], out int page)) { - _page = int.Parse(PageState.QueryString["page"]); + _page = page; } else { diff --git a/Oqtane.Client/Oqtane.Client.csproj b/Oqtane.Client/Oqtane.Client.csproj index b542bf7a..4823e285 100644 --- a/Oqtane.Client/Oqtane.Client.csproj +++ b/Oqtane.Client/Oqtane.Client.csproj @@ -1,10 +1,10 @@ - net8.0 + net9.0 Exe Debug;Release - 5.2.2 + 6.0.0 Oqtane Shaun Walker .NET Foundation @@ -12,7 +12,7 @@ .NET Foundation https://www.oqtane.org https://github.com/oqtane/oqtane.framework/blob/dev/LICENSE - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 https://github.com/oqtane/oqtane.framework Git Oqtane @@ -22,11 +22,10 @@ - - - - - + + + + diff --git a/Oqtane.Client/Program.cs b/Oqtane.Client/Program.cs index 9f5ed2ff..d71131cb 100644 --- a/Oqtane.Client/Program.cs +++ b/Oqtane.Client/Program.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; using System.IO; using System.IO.Compression; @@ -13,13 +12,13 @@ using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using Microsoft.AspNetCore.Localization; using Microsoft.Extensions.DependencyInjection; using Microsoft.JSInterop; using Oqtane.Documentation; using Oqtane.Models; using Oqtane.Modules; using Oqtane.Services; +using Oqtane.Shared; using Oqtane.UI; namespace Oqtane.Client @@ -258,7 +257,7 @@ namespace Oqtane.Client var jsRuntime = serviceProvider.GetRequiredService(); var interop = new Interop(jsRuntime); var localizationCookie = await interop.GetCookie(CookieRequestCultureProvider.DefaultCookieName); - var culture = CookieRequestCultureProvider.ParseCookieValue(localizationCookie)?.UICultures?[0].Value; + var culture = CookieRequestCultureProvider.ParseCookieValue(localizationCookie)?.UICulture.Name; var localizationService = serviceProvider.GetRequiredService(); var cultures = await localizationService.GetCulturesAsync(false); diff --git a/Oqtane.Client/Resources/Installer/Installer.resx b/Oqtane.Client/Resources/Installer/Installer.resx index 23e0f7d7..a06752d9 100644 --- a/Oqtane.Client/Resources/Installer/Installer.resx +++ b/Oqtane.Client/Resources/Installer/Installer.resx @@ -183,4 +183,7 @@ Select a site template + + The Username Provided Does Not Meet The System Requirement, It Can Only Contains Letters Or Digits. + \ No newline at end of file diff --git a/Oqtane.Client/Resources/Modules/Admin/Search/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Search/Index.resx index 0f8e26f4..e9c7d34f 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Search/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Search/Index.resx @@ -139,7 +139,7 @@ Ignore Entities: - Comma delimited list of entities which should be ignored + Comma delimited list of entities which should be ignored. By default File entities are ignored. Word Length: @@ -154,7 +154,7 @@ Comma delimited list of words which should be ignored - Search Settings Saved Successfully + Search Settings Saved Successfully. You Will Need Reindex For Your Changes To Be Reflected In The Search Results. Error Saving Search Settings diff --git a/Oqtane.Client/Resources/Modules/Admin/Site/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Site/Index.resx index b232fe61..1f3bf2c6 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Site/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Site/Index.resx @@ -163,7 +163,7 @@ Enter the site name - The name of the database used for the site + The name of the database used for the site. Note that this is not the physical database name but rather the tenant name which is used within the framework to identify a database. The urls for the site. This can include domain names (ie. domain.com), subdomains (ie. sub.domain.com) or virtual folders (ie. domain.com/folder). @@ -307,7 +307,7 @@ Type: - The connection information for the database + The name of the connection string in appsettings.json which will be used to connect to the database The type of database diff --git a/Oqtane.Client/Resources/Modules/Admin/Sites/Add.resx b/Oqtane.Client/Resources/Modules/Admin/Sites/Add.resx index e0390b08..d32522e9 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Sites/Add.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Sites/Add.resx @@ -187,7 +187,7 @@ Select the database for the site - Enter the name for the database + Enter the name for the database. Note that this will be the tenant name which is used within the framework to identify the database. Select the database type diff --git a/Oqtane.Client/Resources/Modules/Admin/Upgrade/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Upgrade/Index.resx index 40d8af4c..e9a376e1 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Upgrade/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Upgrade/Index.resx @@ -150,4 +150,7 @@ You Cannot Perform A System Update In A Development Environment + + Please Note That The System Update Capability Is A Simplified Upgrade Process Intended For Small To Medium Sized Installations. For Larger Enterprise Installations You Will Want To Use A Manual Upgrade Process. Also Note That The System Update Capability Is Not Recommended When Using Microsoft Azure Due To The Limitations Of That Environment. + \ No newline at end of file diff --git a/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx b/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx index 7e6b222d..a6f0a739 100644 --- a/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/UserProfile/Index.resx @@ -243,4 +243,7 @@ No notifications have been sent + + Logout Everywhere + \ No newline at end of file diff --git a/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx index 6baae9c1..381eff20 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx @@ -385,10 +385,22 @@ Parameters: - Optionally provide the type name of the role claim provided by the identity provider. These roles will be used in addition to any internal user roles assigned within the site. + Optionally provide the type name of the roles claim provided by the identity provider (the standard default is 'roles'). If role names from the identity provider do not exactly match your site role names, please use the Role Claim Mappings. - Role Claim: + Roles Claim: + + + Optionally provide a comma delimited list of role names provided by the identity provider, as well as mappings to your site roles. For example if the identity provider includes an 'Admin' role name and you want it to map to the 'Administrators' site role you should specify 'Admin:Administrators'. + + + Role Claim Mappings: + + + This option will add or remove role assignments so that the site roles exactly match the roles provided by the identity provider for a user + + + Synchronize Roles? Optionally provide a comma delimited list of user profile claim type names provided by the identity provider, as well as mappings to your user profile definition. For example if the identity provider includes a 'given_name' claim and you have a 'FirstName' user profile definition you should specify 'given_name:FirstName'. @@ -459,13 +471,28 @@ Review Claims? - + This option will record the full list of Claims returned by the Provider in the Event Log. It should only be used for testing purposes. External Login will be restricted when this option is enabled. Optionally specify the type name of the user's name claim provided by the identity provider. The typical value is 'name'. - + Name Claim: + + Select the external login provider + + + Provider: + + + Info + + + OAuth 2.0 + + + OpenID Connect (OIDC) + \ No newline at end of file diff --git a/Oqtane.Client/Services/Interfaces/ILocalizationCookieService.cs b/Oqtane.Client/Services/Interfaces/ILocalizationCookieService.cs new file mode 100644 index 00000000..a422d432 --- /dev/null +++ b/Oqtane.Client/Services/Interfaces/ILocalizationCookieService.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; + +namespace Oqtane.Services +{ + /// + /// Service to set localization cookie + /// + public interface ILocalizationCookieService + { + /// + /// Set the localization cookie + /// + /// + /// + Task SetLocalizationCookieAsync(string culture); + } +} diff --git a/Oqtane.Client/Services/Interfaces/IUserService.cs b/Oqtane.Client/Services/Interfaces/IUserService.cs index c159c4bd..b32a66f0 100644 --- a/Oqtane.Client/Services/Interfaces/IUserService.cs +++ b/Oqtane.Client/Services/Interfaces/IUserService.cs @@ -75,6 +75,13 @@ namespace Oqtane.Services /// Task LogoutUserAsync(User user); + /// + /// Logout a + /// + /// + /// + Task LogoutUserEverywhereAsync(User user); + /// /// Update e-mail verification status of a user. /// @@ -106,6 +113,15 @@ namespace Oqtane.Services /// Task VerifyTwoFactorAsync(User user, string token); + /// + /// Validate identity user info. + /// + /// + /// + /// + /// + Task ValidateUserAsync(string username, string email, string password); + /// /// Validate a users password against the password policy /// diff --git a/Oqtane.Client/Services/LocalizationCookieService.cs b/Oqtane.Client/Services/LocalizationCookieService.cs new file mode 100644 index 00000000..330607e6 --- /dev/null +++ b/Oqtane.Client/Services/LocalizationCookieService.cs @@ -0,0 +1,18 @@ +using System.Net.Http; +using System.Threading.Tasks; +using Oqtane.Documentation; +using Oqtane.Shared; + +namespace Oqtane.Services +{ + [PrivateApi("Don't show in the documentation, as everything should use the Interface")] + public class LocalizationCookieService : ServiceBase, ILocalizationCookieService + { + public LocalizationCookieService(HttpClient http, SiteState siteState) : base(http, siteState) { } + + public Task SetLocalizationCookieAsync(string culture) + { + return Task.CompletedTask; // only used in server side rendering + } + } +} diff --git a/Oqtane.Client/Services/RemoteServiceBase.cs b/Oqtane.Client/Services/RemoteServiceBase.cs index 75c1435d..281d4d6c 100644 --- a/Oqtane.Client/Services/RemoteServiceBase.cs +++ b/Oqtane.Client/Services/RemoteServiceBase.cs @@ -4,7 +4,6 @@ using System.Net.Http; using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Net.Http.Headers; using Oqtane.Shared; namespace Oqtane.Services @@ -28,9 +27,9 @@ namespace Oqtane.Services private HttpClient GetHttpClient(string AuthorizationToken) { var httpClient = _httpClientFactory.CreateClient("Remote"); - if (!httpClient.DefaultRequestHeaders.Contains(HeaderNames.Authorization) && !string.IsNullOrEmpty(AuthorizationToken)) + if (!httpClient.DefaultRequestHeaders.Contains("Authorization") && !string.IsNullOrEmpty(AuthorizationToken)) { - httpClient.DefaultRequestHeaders.Add(HeaderNames.Authorization, "Bearer " + AuthorizationToken); + httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer " + AuthorizationToken); } return httpClient; } diff --git a/Oqtane.Client/Services/UserService.cs b/Oqtane.Client/Services/UserService.cs index 286fc2d4..d69aa10d 100644 --- a/Oqtane.Client/Services/UserService.cs +++ b/Oqtane.Client/Services/UserService.cs @@ -61,10 +61,14 @@ namespace Oqtane.Services public async Task LogoutUserAsync(User user) { - // best practices recommend post is preferrable to get for logout await PostJsonAsync($"{Apiurl}/logout", user); } + public async Task LogoutUserEverywhereAsync(User user) + { + await PostJsonAsync($"{Apiurl}/logouteverywhere", user); + } + public async Task VerifyEmailAsync(User user, string token) { return await PostJsonAsync($"{Apiurl}/verify?token={token}", user); @@ -85,6 +89,11 @@ namespace Oqtane.Services return await PostJsonAsync($"{Apiurl}/twofactor?token={token}", user); } + public async Task ValidateUserAsync(string username, string email, string password) + { + return await GetJsonAsync($"{Apiurl}/validateuser?username={WebUtility.UrlEncode(username)}&email={WebUtility.UrlEncode(email)}&password={WebUtility.UrlEncode(password)}"); + } + public async Task ValidatePasswordAsync(string password) { return await GetJsonAsync($"{Apiurl}/validate/{WebUtility.UrlEncode(password)}"); diff --git a/Oqtane.Client/Themes/BlazorTheme/Themes/Default.razor b/Oqtane.Client/Themes/BlazorTheme/Themes/Default.razor index ef853a79..2682018f 100644 --- a/Oqtane.Client/Themes/BlazorTheme/Themes/Default.razor +++ b/Oqtane.Client/Themes/BlazorTheme/Themes/Default.razor @@ -9,7 +9,7 @@
- +
diff --git a/Oqtane.Client/Themes/Controls/Container/ModuleActionsBase.cs b/Oqtane.Client/Themes/Controls/Container/ModuleActionsBase.cs index 93e935b9..35e8ab3e 100644 --- a/Oqtane.Client/Themes/Controls/Container/ModuleActionsBase.cs +++ b/Oqtane.Client/Themes/Controls/Container/ModuleActionsBase.cs @@ -11,9 +11,6 @@ using System.Net; using Microsoft.Extensions.Localization; using Oqtane.UI; -// ReSharper disable UnassignedGetOnlyAutoProperty -// ReSharper disable MemberCanBePrivate.Global - namespace Oqtane.Themes.Controls { public class ModuleActionsBase : ComponentBase @@ -92,20 +89,21 @@ namespace Oqtane.Themes.Controls return actionList; } - private async Task EditUrlAsync(string url, int moduleId, string import) - { - await Task.Yield(); - return Utilities.EditUrl(PageState.Alias.Path, PageState.Page.Path, moduleId, import, ""); - } - protected async Task ModuleAction(ActionViewModel action) { if (PageState.EditMode && UserSecurity.IsAuthorized(PageState.User, PermissionNames.Edit, ModuleState.PermissionList)) { - PageModule pagemodule = await PageModuleService.GetPageModuleAsync(ModuleState.PageModuleId); - - string url = Utilities.NavigateUrl(PageState.Alias.Path, PageState.Page.Path, "edit=true&refresh"); + var url = NavigationManager.Uri.Substring(NavigationManager.BaseUri.Length - 1); + if (!url.Contains("edit=")) + { + url += (!url.Contains("?") ? "?" : "&") + "edit=true"; + } + if (!url.Contains("refresh=")) + { + url += (!url.Contains("?") ? "?" : "&") + "refresh=true"; + } + var pagemodule = await PageModuleService.GetPageModuleAsync(ModuleState.PageModuleId); if (action.Action != null) { url = await action.Action(url, pagemodule); @@ -115,31 +113,10 @@ namespace Oqtane.Themes.Controls } } - private async Task MoveToPane(string url, string newPane, PageModule pagemodule) + private Task Settings(string url, PageModule pagemodule) { - string oldPane = pagemodule.Pane; - pagemodule.Pane = newPane; - pagemodule.Order = int.MaxValue; // add to bottom of pane - await PageModuleService.UpdatePageModuleAsync(pagemodule); - await PageModuleService.UpdatePageModuleOrderAsync(pagemodule.PageId, pagemodule.Pane); - await PageModuleService.UpdatePageModuleOrderAsync(pagemodule.PageId, oldPane); - return url; - } - - private async Task DeleteModule(string url, PageModule pagemodule) - { - pagemodule.IsDeleted = true; - await PageModuleService.UpdatePageModuleAsync(pagemodule); - await PageModuleService.UpdatePageModuleOrderAsync(pagemodule.PageId, pagemodule.Pane); - return url; - } - - private async Task Settings(string url, PageModule pagemodule) - { - await Task.Yield(); - var returnurl = Utilities.NavigateUrl(PageState.Alias.Path, PageState.Page.Path, "edit=true"); - url = Utilities.EditUrl(PageState.Alias.Path, PageState.Page.Path, pagemodule.ModuleId, "Settings", "returnurl=" + WebUtility.UrlEncode(returnurl)); - return url; + url = Utilities.EditUrl(PageState.Alias.Path, PageState.Page.Path, pagemodule.ModuleId, "Settings", "returnurl=" + WebUtility.UrlEncode(url)); + return Task.FromResult(url); } private async Task Publish(string url, PageModule pagemodule) @@ -174,6 +151,20 @@ namespace Oqtane.Themes.Controls return url; } + private async Task DeleteModule(string url, PageModule pagemodule) + { + pagemodule.IsDeleted = true; + await PageModuleService.UpdatePageModuleAsync(pagemodule); + await PageModuleService.UpdatePageModuleOrderAsync(pagemodule.PageId, pagemodule.Pane); + return url; + } + + private Task EditUrlAsync(string url, int moduleId, string import) + { + url = Utilities.EditUrl(PageState.Alias.Path, PageState.Page.Path, moduleId, import, "returnurl=" + WebUtility.UrlEncode(url)); + return Task.FromResult(url); + } + private async Task MoveTop(string url, PageModule pagemodule) { pagemodule.Order = 0; @@ -206,6 +197,17 @@ namespace Oqtane.Themes.Controls return url; } + private async Task MoveToPane(string url, string newPane, PageModule pagemodule) + { + string oldPane = pagemodule.Pane; + pagemodule.Pane = newPane; + pagemodule.Order = int.MaxValue; // add to bottom of pane + await PageModuleService.UpdatePageModuleAsync(pagemodule); + await PageModuleService.UpdatePageModuleOrderAsync(pagemodule.PageId, pagemodule.Pane); + await PageModuleService.UpdatePageModuleOrderAsync(pagemodule.PageId, oldPane); + return url; + } + public class ActionViewModel { public string Icon { get; set; } diff --git a/Oqtane.Client/Themes/Controls/Theme/ControlPanel.razor b/Oqtane.Client/Themes/Controls/Theme/ControlPanel.razor index 9c195681..a58e82b8 100644 --- a/Oqtane.Client/Themes/Controls/Theme/ControlPanel.razor +++ b/Oqtane.Client/Themes/Controls/Theme/ControlPanel.razor @@ -147,8 +147,7 @@ { if (PageState.Page.IsPersonalizable && PageState.User != null && UserSecurity.IsAuthorized(PageState.User, RoleNames.Registered)) { - PageState.EditMode = true; - NavigationManager.NavigateTo(NavigateUrl(page.Path, "edit=" + ((PageState.EditMode) ? "true" : "false"))); + NavigationManager.NavigateTo(NavigateUrl(page.Path, "edit=" + PageState.EditMode.ToString())); } } } diff --git a/Oqtane.Client/Themes/Controls/Theme/ControlPanelInteractive.razor b/Oqtane.Client/Themes/Controls/Theme/ControlPanelInteractive.razor index 524f264b..57fd5b26 100644 --- a/Oqtane.Client/Themes/Controls/Theme/ControlPanelInteractive.razor +++ b/Oqtane.Client/Themes/Controls/Theme/ControlPanelInteractive.razor @@ -454,7 +454,7 @@ { foreach (var permission in PageState.Page.PermissionList.Where(item => item.PermissionName == pagePermission)) { - permissions.Add(new Permission { SiteId = siteId, EntityName = EntityNames.Module, PermissionName = modulePermission, RoleId = permission.RoleId, UserId = permission.UserId, IsAuthorized = permission.IsAuthorized }); + permissions.Add(new Permission { SiteId = siteId, EntityName = EntityNames.Module, PermissionName = modulePermission, RoleName = permission.RoleName, UserId = permission.UserId, IsAuthorized = permission.IsAuthorized }); } return permissions; } diff --git a/Oqtane.Client/Themes/Controls/Theme/LanguageSwitcher.razor b/Oqtane.Client/Themes/Controls/Theme/LanguageSwitcher.razor index c02548e0..eaf3f48c 100644 --- a/Oqtane.Client/Themes/Controls/Theme/LanguageSwitcher.razor +++ b/Oqtane.Client/Themes/Controls/Theme/LanguageSwitcher.razor @@ -1,10 +1,9 @@ @using System.Globalization -@using Microsoft.AspNetCore.Localization -@using Microsoft.AspNetCore.Http @using Oqtane.Models @namespace Oqtane.Themes.Controls @inherits ThemeControlBase @inject ILanguageService LanguageService +@inject ILocalizationCookieService LocalizationCookieService @inject NavigationManager NavigationManager @if (_supportedCultures?.Count() > 1) @@ -22,7 +21,7 @@ } else { - @culture.DisplayName + @culture.DisplayName } }
@@ -38,25 +37,20 @@ [Parameter] public string ButtonClass { get; set; } = "btn-outline-secondary"; - [CascadingParameter] - HttpContext HttpContext { get; set; } - - protected override void OnParametersSet() + protected override async Task OnParametersSetAsync() { MenuAlignment = DropdownAlignment.ToLower() == "right" ? "dropdown-menu-end" : string.Empty; - var languages = PageState.Languages; - _supportedCultures = languages.Select(l => new Culture { Name = l.Code, DisplayName = l.Name }); + _supportedCultures = PageState.Languages.Select(l => new Culture { Name = l.Code, DisplayName = l.Name }); if (PageState.QueryString.ContainsKey("culture")) { var culture = PageState.QueryString["culture"]; if (_supportedCultures.Any(item => item.Name == culture)) { - var localizationCookieValue = CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)); - HttpContext.Response.Cookies.Append(CookieRequestCultureProvider.DefaultCookieName, localizationCookieValue, new CookieOptions { Path = "/", Expires = DateTimeOffset.UtcNow.AddYears(365) }); + await LocalizationCookieService.SetLocalizationCookieAsync(culture); } - NavigationManager.NavigateTo(NavigationManager.Uri.Replace($"?culture={culture}", ""), forceLoad: true); + NavigationManager.NavigateTo(NavigationManager.Uri.Replace($"?culture={culture}", "")); } } @@ -66,8 +60,8 @@ { var localizationCookieValue = CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)); var interop = new Interop(JSRuntime); - await interop.SetCookie(CookieRequestCultureProvider.DefaultCookieName, localizationCookieValue, 360); - NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true); + await interop.SetCookie(CookieRequestCultureProvider.DefaultCookieName, localizationCookieValue, 360, true, "Lax"); + NavigationManager.NavigateTo(NavigationManager.Uri, true); } } } diff --git a/Oqtane.Client/Themes/Controls/Theme/Login.razor b/Oqtane.Client/Themes/Controls/Theme/Login.razor index f070d2c1..8b01e34c 100644 --- a/Oqtane.Client/Themes/Controls/Theme/Login.razor +++ b/Oqtane.Client/Themes/Controls/Theme/Login.razor @@ -4,31 +4,28 @@ @inject IStringLocalizer SharedLocalizer - - - ... - - - @if (PageState.Runtime == Runtime.Hybrid) - { - - } - else - { -
- - - -
- } -
- - @if (ShowLogin) - { - @SharedLocalizer["Login"] - } - -
+ @if (PageState.User != null) + { + @if (PageState.Runtime == Runtime.Hybrid) + { + + } + else + { +
+ + + +
+ } + } + else + { + @if (ShowLogin) + { + @SharedLocalizer["Login"] + } + }
@code diff --git a/Oqtane.Client/Themes/Controls/Theme/Logo.razor b/Oqtane.Client/Themes/Controls/Theme/Logo.razor index 594649f1..3e1f26d7 100644 --- a/Oqtane.Client/Themes/Controls/Theme/Logo.razor +++ b/Oqtane.Client/Themes/Controls/Theme/Logo.razor @@ -8,4 +8,19 @@ @PageState.Site.Name -} \ No newline at end of file +} +else +{ + if (UseSiteNameAsFallback) + { + + @PageState.Site.Name + + } +} + +@code { + [Parameter] + public bool UseSiteNameAsFallback { get; set; } = false; // indicates if the site name should be displayed in scenarios where a site does not have a logo defined +} + diff --git a/Oqtane.Client/Themes/Controls/Theme/Search.razor b/Oqtane.Client/Themes/Controls/Theme/Search.razor index 19eec4cc..ccb4b327 100644 --- a/Oqtane.Client/Themes/Controls/Theme/Search.razor +++ b/Oqtane.Client/Themes/Controls/Theme/Search.razor @@ -18,7 +18,7 @@ placeholder="@Localizer["SearchPlaceHolder"]" aria-label="Search" /> } - diff --git a/Oqtane.Client/Themes/Controls/Theme/UserProfile.razor b/Oqtane.Client/Themes/Controls/Theme/UserProfile.razor index dee72bbe..7646d320 100644 --- a/Oqtane.Client/Themes/Controls/Theme/UserProfile.razor +++ b/Oqtane.Client/Themes/Controls/Theme/UserProfile.razor @@ -6,20 +6,17 @@ @inject NavigationManager NavigationManager - - - ... - - - @context.User.Identity.Name - - - @if (ShowRegister && PageState.Site.AllowRegistration) - { - @Localizer["Register"] - } - - + @if (PageState.User != null) + { + @PageState.User.Username + } + else + { + @if (ShowRegister && PageState.Site.AllowRegistration) + { + @Localizer["Register"] + } + } @code { diff --git a/Oqtane.Client/Themes/OqtaneTheme/Themes/Default.razor b/Oqtane.Client/Themes/OqtaneTheme/Themes/Default.razor index e00dd363..4814ad2a 100644 --- a/Oqtane.Client/Themes/OqtaneTheme/Themes/Default.razor +++ b/Oqtane.Client/Themes/OqtaneTheme/Themes/Default.razor @@ -4,7 +4,7 @@
- +
diff --git a/Oqtane.Client/UI/Interop.cs b/Oqtane.Client/UI/Interop.cs index cc379dc6..c5964c96 100644 --- a/Oqtane.Client/UI/Interop.cs +++ b/Oqtane.Client/UI/Interop.cs @@ -16,13 +16,18 @@ namespace Oqtane.UI _jsRuntime = jsRuntime; } - public Task SetCookie(string name, string value, int days) + public async Task SetCookie(string name, string value, int days) + { + await SetCookie(name, value, days, true, "Lax"); + } + + public Task SetCookie(string name, string value, int days, bool secure, string sameSite) { try { _jsRuntime.InvokeVoidAsync( "Oqtane.Interop.setCookie", - name, value, days); + name, value, days, secure, sameSite); return Task.CompletedTask; } catch diff --git a/Oqtane.Client/UI/Routes.razor b/Oqtane.Client/UI/Routes.razor index fb88abcb..20dc0a09 100644 --- a/Oqtane.Client/UI/Routes.razor +++ b/Oqtane.Client/UI/Routes.razor @@ -1,7 +1,5 @@ @namespace Oqtane.UI -@using Microsoft.AspNetCore.Http @inject IInstallationService InstallationService -@inject IJSRuntime JSRuntime @inject SiteState SiteState @if (_initialized) @@ -48,9 +46,6 @@ [Parameter] public string Platform { get; set; } = ""; - [CascadingParameter] - HttpContext HttpContext { get; set; } - private bool _initialized = false; private bool _installed = false; private string _display = "display: none;"; @@ -61,9 +56,8 @@ { SiteState.AntiForgeryToken = AntiForgeryToken; SiteState.AuthorizationToken = AuthorizationToken; - SiteState.RemoteIPAddress = (_pageState != null) ? _pageState.RemoteIPAddress : ""; SiteState.Platform = Platform; - SiteState.IsPrerendering = (HttpContext != null) ? true : false; + SiteState.IsPrerendering = !RendererInfo.IsInteractive; if (Runtime == Runtimes.Hybrid) { @@ -80,6 +74,7 @@ { _pageState = PageState; SiteState.Alias = PageState.Alias; + SiteState.RemoteIPAddress = (PageState != null) ? PageState.RemoteIPAddress : ""; _installed = true; } } diff --git a/Oqtane.Client/UI/SiteRouter.razor b/Oqtane.Client/UI/SiteRouter.razor index 7c4cde03..31ac6c21 100644 --- a/Oqtane.Client/UI/SiteRouter.razor +++ b/Oqtane.Client/UI/SiteRouter.razor @@ -1,6 +1,5 @@ @using System.Diagnostics.CodeAnalysis @using System.Net -@using Microsoft.AspNetCore.Http @using System.Globalization @using System.Security.Claims @namespace Oqtane.UI @@ -157,7 +156,7 @@ // verify user is authenticated for current site var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); - if (authState.User.Identity.IsAuthenticated && authState.User.Claims.Any(item => item.Type == "sitekey" && item.Value == SiteState.Alias.SiteKey)) + if (authState.User.Identity.IsAuthenticated && authState.User.Claims.Any(item => item.Type == Constants.SiteKeyClaimType && item.Value == SiteState.Alias.SiteKey)) { // get user var userid = int.Parse(authState.User.Claims.First(item => item.Type == ClaimTypes.NameIdentifier).Value); @@ -287,10 +286,10 @@ } // load additional metadata for current page - page = ProcessPage(page, site, user, SiteState.Alias); + page = ProcessPage(page, site, user, SiteState.Alias, action); // load additional metadata for modules - (page, modules) = ProcessModules(page, modules, moduleid, action, (!string.IsNullOrEmpty(page.DefaultContainerType)) ? page.DefaultContainerType : site.DefaultContainerType, SiteState.Alias); + (page, modules) = ProcessModules(site, page, modules, moduleid, action, (!string.IsNullOrEmpty(page.DefaultContainerType)) ? page.DefaultContainerType : site.DefaultContainerType, SiteState.Alias); // populate page state (which acts as a client-side cache for subsequent requests) _pagestate = new PageState @@ -366,7 +365,7 @@ } } - private Page ProcessPage(Page page, Site site, User user, Alias alias) + private Page ProcessPage(Page page, Site site, User user, Alias alias, string action) { try { @@ -403,6 +402,16 @@ page.Resources = ManagePageResources(page.Resources, themeobject.Resources, ResourceLevel.Page, alias, "Themes", themetype.Namespace); } } + // theme settings components are dynamically loaded within the framework Page Management module + if (page.Path == "admin/pages" && action.ToLower() == "edit" && theme != null && !string.IsNullOrEmpty(theme.ThemeSettingsType)) + { + var settingsType = Type.GetType(theme.ThemeSettingsType); + if (settingsType != null) + { + var objSettings = Activator.CreateInstance(settingsType) as IModuleControl; + page.Resources = ManagePageResources(page.Resources, objSettings.Resources, ResourceLevel.Module, alias, "Modules", settingsType.Namespace); + } + } if (!string.IsNullOrEmpty(panes)) { @@ -426,7 +435,7 @@ return page; } - private (Page Page, List Modules) ProcessModules(Page page, List modules, int moduleid, string action, string defaultcontainertype, Alias alias) + private (Page Page, List Modules) ProcessModules(Site site, Page page, List modules, int moduleid, string action, string defaultcontainertype, Alias alias) { var paneindex = new Dictionary(); @@ -494,15 +503,40 @@ module.Prerender = moduleobject.Prerender; page.Resources = ManagePageResources(page.Resources, moduleobject.Resources, ResourceLevel.Module, alias, "Modules", moduletype.Namespace); + + // settings components are dynamically loaded within the framework Settings module if (action.ToLower() == "settings" && module.ModuleDefinition != null) { - // settings components are embedded within a framework settings module - moduletype = Type.GetType(module.ModuleDefinition.ControlTypeTemplate.Replace(Constants.ActionToken, action), false, true); + // module settings component + var settingsType = ""; + if (!string.IsNullOrEmpty(module.ModuleDefinition.SettingsType)) + { + // module settings type explicitly declared in IModule interface + settingsType = module.ModuleDefinition.SettingsType; + } + else + { + // legacy support - module settings type determined by convention + settingsType = module.ModuleDefinition.ControlTypeTemplate.Replace(Constants.ActionToken, action); + } + moduletype = Type.GetType(settingsType, false, true); if (moduletype != null) { moduleobject = Activator.CreateInstance(moduletype) as IModuleControl; page.Resources = ManagePageResources(page.Resources, moduleobject.Resources, ResourceLevel.Module, alias, "Modules", moduletype.Namespace); } + + // container settings component + var theme = site.Themes.FirstOrDefault(item => item.Themes.Any(item => item.TypeName == page.ThemeType)); + if (theme != null && !string.IsNullOrEmpty(theme.ContainerSettingsType)) + { + moduletype = Type.GetType(theme.ContainerSettingsType); + if (moduletype != null) + { + moduleobject = Activator.CreateInstance(moduletype) as IModuleControl; + page.Resources = ManagePageResources(page.Resources, moduleobject.Resources, ResourceLevel.Module, alias, "Modules", moduletype.Namespace); + } + } } // additional metadata needed for admin components diff --git a/Oqtane.Client/UI/ThemeBuilder.razor b/Oqtane.Client/UI/ThemeBuilder.razor index a563dfd8..3058f4f4 100644 --- a/Oqtane.Client/UI/ThemeBuilder.razor +++ b/Oqtane.Client/UI/ThemeBuilder.razor @@ -20,6 +20,13 @@ return; } + // force authenticated user to provide email address (email may be missing if using external login) + if (PageState.User != null && PageState.User.IsAuthenticated && string.IsNullOrEmpty(PageState.User.Email) && PageState.Route.PagePath != "profile") + { + NavigationManager.NavigateTo(Utilities.NavigateUrl(PageState.Alias.Path, "profile", "returnurl=" + WebUtility.UrlEncode(PageState.Route.PathAndQuery))); + return; + } + // set page title if (!string.IsNullOrEmpty(PageState.Page.Title)) { @@ -44,7 +51,6 @@ } // head content - AddHeadContent(headcontent, PageState.Site.HeadContent); if (!string.IsNullOrEmpty(PageState.Site.HeadContent)) { headcontent = AddHeadContent(headcontent, PageState.Site.HeadContent); @@ -66,30 +72,24 @@ { if (!string.IsNullOrEmpty(content)) { - if (PageState.RenderMode == RenderModes.Interactive) + var elements = content.Split('<', StringSplitOptions.RemoveEmptyEntries); + foreach (var element in elements) { - // remove scripts - var index = content.IndexOf("= 0) + if (PageState.RenderMode == RenderModes.Static || (!element.ToLower().StartsWith("script") && !element.ToLower().StartsWith("/script"))) { - content = content.Remove(index, content.IndexOf("") + 9 - index); - index = content.IndexOf(" - net8.0 - 5.2.2 + net9.0 + 6.0.0 Oqtane Shaun Walker .NET Foundation @@ -10,7 +10,7 @@ .NET Foundation https://www.oqtane.org https://github.com/oqtane/oqtane.framework/blob/dev/LICENSE - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 https://github.com/oqtane/oqtane.framework Git true @@ -33,8 +33,8 @@ - - + + @@ -42,7 +42,7 @@ - + diff --git a/Oqtane.Database.PostgreSQL/Oqtane.Database.PostgreSQL.csproj b/Oqtane.Database.PostgreSQL/Oqtane.Database.PostgreSQL.csproj index 18619910..09f36744 100644 --- a/Oqtane.Database.PostgreSQL/Oqtane.Database.PostgreSQL.csproj +++ b/Oqtane.Database.PostgreSQL/Oqtane.Database.PostgreSQL.csproj @@ -1,8 +1,8 @@ - net8.0 - 5.2.2 + net9.0 + 6.0.0 Oqtane Shaun Walker .NET Foundation @@ -10,7 +10,7 @@ .NET Foundation https://www.oqtane.org https://github.com/oqtane/oqtane.framework/blob/dev/LICENSE - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 https://github.com/oqtane/oqtane.framework Git true @@ -25,17 +25,18 @@ - 1701;1702;EF1001;AD0001 + 1701;1702;EF1001;AD0001;NU1608 - 1701;1702;EF1001;AD0001 + 1701;1702;EF1001;AD0001;NU1608 - - + + + @@ -43,7 +44,7 @@ - + diff --git a/Oqtane.Database.SqlServer/Oqtane.Database.SqlServer.csproj b/Oqtane.Database.SqlServer/Oqtane.Database.SqlServer.csproj index 7fdfae00..8c5a767b 100644 --- a/Oqtane.Database.SqlServer/Oqtane.Database.SqlServer.csproj +++ b/Oqtane.Database.SqlServer/Oqtane.Database.SqlServer.csproj @@ -1,8 +1,8 @@ - net8.0 - 5.2.2 + net9.0 + 6.0.0 Oqtane Shaun Walker .NET Foundation @@ -10,7 +10,7 @@ .NET Foundation https://www.oqtane.org https://github.com/oqtane/oqtane.framework/blob/dev/LICENSE - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 https://github.com/oqtane/oqtane.framework Git true @@ -33,7 +33,7 @@ - + @@ -41,7 +41,7 @@ - + diff --git a/Oqtane.Database.Sqlite/Oqtane.Database.Sqlite.csproj b/Oqtane.Database.Sqlite/Oqtane.Database.Sqlite.csproj index 1fe344e3..254b21db 100644 --- a/Oqtane.Database.Sqlite/Oqtane.Database.Sqlite.csproj +++ b/Oqtane.Database.Sqlite/Oqtane.Database.Sqlite.csproj @@ -1,8 +1,8 @@ - net8.0 - 5.2.2 + net9.0 + 6.0.0 Oqtane Shaun Walker .NET Foundation @@ -10,7 +10,7 @@ .NET Foundation https://www.oqtane.org https://github.com/oqtane/oqtane.framework/blob/dev/LICENSE - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 https://github.com/oqtane/oqtane.framework Git true @@ -33,7 +33,7 @@ - + @@ -41,7 +41,7 @@ - + diff --git a/Oqtane.Maui/App.xaml.cs b/Oqtane.Maui/App.xaml.cs index 0d60ea85..95ffaf1b 100644 --- a/Oqtane.Maui/App.xaml.cs +++ b/Oqtane.Maui/App.xaml.cs @@ -5,7 +5,10 @@ public partial class App : Application public App() { InitializeComponent(); - - MainPage = new MainPage(); } + + protected override Window CreateWindow(IActivationState activationState) + { + return new Window(new MainPage()); + } } diff --git a/Oqtane.Maui/Oqtane.Maui.csproj b/Oqtane.Maui/Oqtane.Maui.csproj index f6b66171..32d34828 100644 --- a/Oqtane.Maui/Oqtane.Maui.csproj +++ b/Oqtane.Maui/Oqtane.Maui.csproj @@ -1,12 +1,12 @@ - $(TargetFrameworks);net8.0-windows10.0.19041.0 + $(TargetFrameworks);net9.0-windows10.0.19041.0 - - + + Exe - 5.2.2 + 6.0.0 Oqtane Shaun Walker .NET Foundation @@ -14,7 +14,7 @@ .NET Foundation https://www.oqtane.org https://github.com/oqtane/oqtane.framework/blob/dev/LICENSE - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 https://github.com/oqtane/oqtane.framework Git Oqtane.Maui @@ -28,19 +28,21 @@ com.oqtane.maui - 0E29FC31-1B83-48ED-B6E0-9F3C67B775D4 - 5.2.2 + 6.0.0 1 - 14.2 - 14.0 - 24.0 - 10.0.17763.0 - 10.0.17763.0 - 6.5 - + + None + + 15.0 + 15.0 + 24.0 + 10.0.17763.0 + 10.0.17763.0 + 6.5 + @@ -65,23 +67,22 @@ - - - - - - - - - + + + + + + + + - ..\Oqtane.Server\bin\Debug\net8.0\Oqtane.Client.dll + ..\Oqtane.Server\bin\Debug\net9.0\Oqtane.Client.dll - ..\Oqtane.Server\bin\Debug\net8.0\Oqtane.Shared.dll + ..\Oqtane.Server\bin\Debug\net9.0\Oqtane.Shared.dll diff --git a/Oqtane.Maui/Properties/launchSettings.json b/Oqtane.Maui/Properties/launchSettings.json index edf8aadc..4f857936 100644 --- a/Oqtane.Maui/Properties/launchSettings.json +++ b/Oqtane.Maui/Properties/launchSettings.json @@ -1,7 +1,7 @@ { "profiles": { "Windows Machine": { - "commandName": "MsixPackage", + "commandName": "Project", "nativeDebugging": false } } diff --git a/Oqtane.Maui/wwwroot/css/app.css b/Oqtane.Maui/wwwroot/css/app.css index 863d10d2..538e3a4c 100644 --- a/Oqtane.Maui/wwwroot/css/app.css +++ b/Oqtane.Maui/wwwroot/css/app.css @@ -35,6 +35,9 @@ app { } /* Action Dialog */ +.app-actiondialog{ + position: absolute; +} .app-actiondialog .modal { position: fixed; /* Stay in place */ z-index: 9999; /* Sit on top */ @@ -230,5 +233,41 @@ app { } .app-form-inline { - display: inline-block; + display: inline; } +.app-search{ + display: inline-block; + position: relative; +} +.app-search input + button{ + background: none; + border: none; + position: absolute; + right: 0; + top: 0; +} +.app-search input + button .oi{ + top: 0; +} +.app-search-noinput { + display: inline-block; + position: relative; +} +.app-search-noinput button { + background: none; + border: none; + color: var(--bs-heading-color); +} +.app-search-noinput button:hover { + color: var(--bs-heading-color); +} + +/* Text Editor */ +.text-area-editor > textarea { + width: 100%; + min-height: 250px; +} + +.app-logo .navbar-brand { + padding: 5px 20px 5px 20px; +} \ No newline at end of file diff --git a/Oqtane.Maui/wwwroot/js/interop.js b/Oqtane.Maui/wwwroot/js/interop.js index 8305f766..675cebca 100644 --- a/Oqtane.Maui/wwwroot/js/interop.js +++ b/Oqtane.Maui/wwwroot/js/interop.js @@ -1,11 +1,18 @@ var Oqtane = Oqtane || {}; Oqtane.Interop = { - setCookie: function (name, value, days) { + setCookie: function (name, value, days, secure, sameSite) { var d = new Date(); d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000)); var expires = "expires=" + d.toUTCString(); - document.cookie = name + "=" + value + ";" + expires + ";path=/"; + var cookieString = name + "=" + value + ";" + expires + ";path=/"; + if (secure) { + cookieString += "; secure"; + } + if (sameSite === "Lax" || sameSite === "Strict" || sameSite === "None") { + cookieString += "; SameSite=" + sameSite; + } + document.cookie = cookieString; }, getCookie: function (name) { name = name + "="; @@ -198,7 +205,9 @@ Oqtane.Interop = { } promises.push(new Promise((resolve, reject) => { if (loadjs.isDefined(bundles[b])) { - resolve(true); + loadjs.ready(bundles[b], () => { + resolve(true); + }); } else { loadjs(urls, bundles[b], { @@ -206,18 +215,25 @@ Oqtane.Interop = { returnPromise: true, before: function (path, element) { for (let s = 0; s < scripts.length; s++) { - if (path === scripts[s].href && scripts[s].integrity !== '') { - element.integrity = scripts[s].integrity; - } - if (path === scripts[s].href && scripts[s].crossorigin !== '') { - element.crossOrigin = scripts[s].crossorigin; - } - if (path === scripts[s].href && scripts[s].es6module === true) { - element.type = "module"; - } - if (path === scripts[s].href && scripts[s].location === 'body') { - document.body.appendChild(element); - return false; // return false to bypass default DOM insertion mechanism + if (path === scripts[s].href) { + if (scripts[s].integrity !== '') { + element.integrity = scripts[s].integrity; + } + if (scripts[s].crossorigin !== '') { + element.crossOrigin = scripts[s].crossorigin; + } + if (scripts[s].es6module === true) { + element.type = "module"; + } + if (typeof scripts[s].dataAttributes !== "undefined" && scripts[s].dataAttributes !== null) { + for (var key in scripts[s].dataAttributes) { + element.setAttribute(key, scripts[s].dataAttributes[key]); + } + } + if (scripts[s].location === 'body') { + document.body.appendChild(element); + return false; // return false to bypass default DOM insertion mechanism + } } } } @@ -286,41 +302,49 @@ Oqtane.Interop = { }, uploadFiles: function (posturl, folder, id, antiforgerytoken, jwt) { var fileinput = document.getElementById('FileInput_' + id); - var files = fileinput.files; var progressinfo = document.getElementById('ProgressInfo_' + id); var progressbar = document.getElementById('ProgressBar_' + id); if (progressinfo !== null && progressbar !== null) { progressinfo.setAttribute("style", "display: inline;"); + progressinfo.innerHTML = ''; progressbar.setAttribute("style", "width: 100%; display: inline;"); + progressbar.value = 0; } + var files = fileinput.files; + var totalSize = 0; for (var i = 0; i < files.length; i++) { - var FileChunk = []; - var file = files[i]; - var MaxFileSizeMB = 1; - var BufferChunkSize = MaxFileSizeMB * (1024 * 1024); - var FileStreamPos = 0; - var EndPos = BufferChunkSize; - var Size = file.size; + totalSize = totalSize + files[i].size; + } - while (FileStreamPos < Size) { - FileChunk.push(file.slice(FileStreamPos, EndPos)); - FileStreamPos = EndPos; - EndPos = FileStreamPos + BufferChunkSize; + var maxChunkSizeMB = 1; + var bufferChunkSize = maxChunkSizeMB * (1024 * 1024); + var uploadedSize = 0; + + for (var i = 0; i < files.length; i++) { + var fileChunk = []; + var file = files[i]; + var fileStreamPos = 0; + var endPos = bufferChunkSize; + + while (fileStreamPos < file.size) { + fileChunk.push(file.slice(fileStreamPos, endPos)); + fileStreamPos = endPos; + endPos = fileStreamPos + bufferChunkSize; } - var TotalParts = FileChunk.length; - var PartCount = 0; + var totalParts = fileChunk.length; + var partCount = 0; - while (Chunk = FileChunk.shift()) { - PartCount++; - var FileName = file.name + ".part_" + PartCount.toString().padStart(3, '0') + "_" + TotalParts.toString().padStart(3, '0'); + while (chunk = fileChunk.shift()) { + partCount++; + var fileName = file.name + ".part_" + partCount.toString().padStart(3, '0') + "_" + totalParts.toString().padStart(3, '0'); var data = new FormData(); data.append('__RequestVerificationToken', antiforgerytoken); data.append('folder', folder); - data.append('formfile', Chunk, FileName); + data.append('formfile', chunk, fileName); var request = new XMLHttpRequest(); request.open('POST', posturl, true); if (jwt !== "") { @@ -328,28 +352,36 @@ Oqtane.Interop = { request.withCredentials = true; } request.upload.onloadstart = function (e) { - if (progressinfo !== null && progressbar !== null) { - progressinfo.innerHTML = file.name + ' 0%'; - progressbar.value = 0; + if (progressinfo !== null && progressbar !== null && progressinfo.innerHTML === '') { + if (files.length === 1) { + progressinfo.innerHTML = file.name; + } + else { + progressinfo.innerHTML = file.name + ", ..."; + } } }; request.upload.onprogress = function (e) { if (progressinfo !== null && progressbar !== null) { - var percent = Math.ceil((e.loaded / e.total) * 100); - progressinfo.innerHTML = file.name + '[' + PartCount + '] ' + percent + '%'; + var percent = Math.ceil(((uploadedSize + e.loaded) / totalSize) * 100); progressbar.value = (percent / 100); } }; request.upload.onloadend = function (e) { if (progressinfo !== null && progressbar !== null) { - progressinfo.innerHTML = file.name + ' 100%'; - progressbar.value = 1; + uploadedSize = uploadedSize + e.total; + var percent = Math.ceil((uploadedSize / totalSize) * 100); + progressbar.value = (percent / 100); } }; request.upload.onerror = function() { if (progressinfo !== null && progressbar !== null) { - progressinfo.innerHTML = file.name + ' Error: ' + request.statusText; - progressbar.value = 0; + if (files.length === 1) { + progressinfo.innerHTML = file.name + ' Error: ' + request.statusText; + } + else { + progressinfo.innerHTML = ' Error: ' + request.statusText; + } } }; request.send(data); @@ -385,11 +417,20 @@ Oqtane.Interop = { } }, scrollTo: function (top, left, behavior) { - window.scrollTo({ - top: top, - left: left, - behavior: behavior - }); + const modal = document.querySelector('.modal'); + if (modal) { + modal.scrollTo({ + top: top, + left: left, + behavior: behavior + }); + } else { + window.scrollTo({ + top: top, + left: left, + behavior: behavior + }); + } }, scrollToId: function (id) { var element = document.getElementById(id); diff --git a/Oqtane.Package/Oqtane.Client.nuspec b/Oqtane.Package/Oqtane.Client.nuspec index 1d9e0234..a01cd05d 100644 --- a/Oqtane.Package/Oqtane.Client.nuspec +++ b/Oqtane.Package/Oqtane.Client.nuspec @@ -1,8 +1,8 @@ - + Oqtane.Client - 5.2.2 + 6.0.0 Shaun Walker .NET Foundation Oqtane Framework @@ -12,14 +12,15 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 readme.md icon.png oqtane - - + + + diff --git a/Oqtane.Package/Oqtane.Framework.nuspec b/Oqtane.Package/Oqtane.Framework.nuspec index 898ed6a2..80b1baaa 100644 --- a/Oqtane.Package/Oqtane.Framework.nuspec +++ b/Oqtane.Package/Oqtane.Framework.nuspec @@ -1,8 +1,8 @@  - + Oqtane.Framework - 5.2.2 + 6.0.0 Shaun Walker .NET Foundation Oqtane Framework @@ -11,13 +11,14 @@ .NET Foundation false MIT - https://github.com/oqtane/oqtane.framework/releases/download/v5.2.2/Oqtane.Framework.5.2.2.Upgrade.zip - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/download/v6.0.0/Oqtane.Framework.6.0.0.Upgrade.zip + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 readme.md icon.png oqtane framework + diff --git a/Oqtane.Package/Oqtane.Server.nuspec b/Oqtane.Package/Oqtane.Server.nuspec index 702ab506..1be6a86d 100644 --- a/Oqtane.Package/Oqtane.Server.nuspec +++ b/Oqtane.Package/Oqtane.Server.nuspec @@ -1,8 +1,8 @@ - + Oqtane.Server - 5.2.2 + 6.0.0 Shaun Walker .NET Foundation Oqtane Framework @@ -12,14 +12,15 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 readme.md icon.png oqtane - - + + + diff --git a/Oqtane.Package/Oqtane.Shared.nuspec b/Oqtane.Package/Oqtane.Shared.nuspec index 78fa19dd..9186c018 100644 --- a/Oqtane.Package/Oqtane.Shared.nuspec +++ b/Oqtane.Package/Oqtane.Shared.nuspec @@ -1,8 +1,8 @@ - + Oqtane.Shared - 5.2.2 + 6.0.0 Shaun Walker .NET Foundation Oqtane Framework @@ -12,14 +12,15 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 readme.md icon.png oqtane - - + + + diff --git a/Oqtane.Package/Oqtane.Updater.nuspec b/Oqtane.Package/Oqtane.Updater.nuspec index 44a4196d..6a7911f8 100644 --- a/Oqtane.Package/Oqtane.Updater.nuspec +++ b/Oqtane.Package/Oqtane.Updater.nuspec @@ -1,8 +1,8 @@  - + Oqtane.Updater - 5.2.2 + 6.0.0 Shaun Walker .NET Foundation Oqtane Framework @@ -12,13 +12,14 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 readme.md icon.png oqtane - + + diff --git a/Oqtane.Package/install.ps1 b/Oqtane.Package/install.ps1 index b6230b38..9f4ef87b 100644 --- a/Oqtane.Package/install.ps1 +++ b/Oqtane.Package/install.ps1 @@ -1 +1 @@ -Compress-Archive -Path "..\Oqtane.Server\bin\Release\net8.0\publish\*" -DestinationPath "Oqtane.Framework.5.2.2.Install.zip" -Force +Compress-Archive -Path "..\Oqtane.Server\bin\Release\net9.0\publish\*" -DestinationPath "Oqtane.Framework.6.0.0.Install.zip" -Force diff --git a/Oqtane.Package/nuget.exe b/Oqtane.Package/nuget.exe index 7d4cdaef..89f85528 100644 Binary files a/Oqtane.Package/nuget.exe and b/Oqtane.Package/nuget.exe differ diff --git a/Oqtane.Package/release.cmd b/Oqtane.Package/release.cmd index 0b11fe27..3aa117db 100644 --- a/Oqtane.Package/release.cmd +++ b/Oqtane.Package/release.cmd @@ -6,14 +6,22 @@ nuget.exe pack Oqtane.Client.nuspec nuget.exe pack Oqtane.Server.nuspec nuget.exe pack Oqtane.Shared.nuspec nuget.exe pack Oqtane.Framework.nuspec -del /F/Q/S "..\Oqtane.Server\bin\Release\net8.0\publish" > NUL -rmdir /Q/S "..\Oqtane.Server\bin\Release\net8.0\publish" +del /F/Q/S "..\Oqtane.Server\bin\Release\net9.0\publish" > NUL +rmdir /Q/S "..\Oqtane.Server\bin\Release\net9.0\publish" dotnet publish ..\Oqtane.Server\Oqtane.Server.csproj /p:Configuration=Release -del /F/Q/S "..\Oqtane.Server\bin\Release\net8.0\publish\wwwroot\Content" > NUL -rmdir /Q/S "..\Oqtane.Server\bin\Release\net8.0\publish\wwwroot\Content" +del /F/Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\wwwroot\Content" > NUL +rmdir /Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\wwwroot\Content" setlocal ENABLEDELAYEDEXPANSION +set retain=Placeholder.txt +for /D %%i in ("..\Oqtane.Server\bin\Release\net9.0\publish\wwwroot\_content\*") do ( +set /A found=0 +for %%j in (%retain%) do ( +if "%%~nxi" == "%%j" set /A found=1 +) +if not !found! == 1 rmdir /Q/S "%%i" +) set retain=Oqtane.Modules.Admin.Login,Oqtane.Modules.HtmlText -for /D %%i in ("..\Oqtane.Server\bin\Release\net8.0\publish\wwwroot\Modules\*") do ( +for /D %%i in ("..\Oqtane.Server\bin\Release\net9.0\publish\wwwroot\Modules\*") do ( set /A found=0 for %%j in (%retain%) do ( if "%%~nxi" == "%%j" set /A found=1 @@ -21,18 +29,18 @@ if "%%~nxi" == "%%j" set /A found=1 if not !found! == 1 rmdir /Q/S "%%i" ) set retain=Oqtane.Themes.BlazorTheme,Oqtane.Themes.OqtaneTheme -for /D %%i in ("..\Oqtane.Server\bin\Release\net8.0\publish\wwwroot\Themes\*") do ( +for /D %%i in ("..\Oqtane.Server\bin\Release\net9.0\publish\wwwroot\Themes\*") do ( set /A found=0 for %%j in (%retain%) do ( if "%%~nxi" == "%%j" set /A found=1 ) if not !found! == 1 rmdir /Q/S "%%i" ) -del "..\Oqtane.Server\bin\Release\net8.0\publish\appsettings.json" -ren "..\Oqtane.Server\bin\Release\net8.0\publish\appsettings.release.json" "appsettings.json" +del "..\Oqtane.Server\bin\Release\net9.0\publish\appsettings.json" +ren "..\Oqtane.Server\bin\Release\net9.0\publish\appsettings.release.json" "appsettings.json" C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe ".\install.ps1" -del "..\Oqtane.Server\bin\Release\net8.0\publish\appsettings.json" -del "..\Oqtane.Server\bin\Release\net8.0\publish\web.config" +del "..\Oqtane.Server\bin\Release\net9.0\publish\appsettings.json" +del "..\Oqtane.Server\bin\Release\net9.0\publish\web.config" C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe ".\upgrade.ps1" dotnet clean -c Release ..\Oqtane.Updater.sln dotnet build -c Release ..\Oqtane.Updater.sln diff --git a/Oqtane.Package/upgrade.ps1 b/Oqtane.Package/upgrade.ps1 index b2f11245..7ece0bea 100644 --- a/Oqtane.Package/upgrade.ps1 +++ b/Oqtane.Package/upgrade.ps1 @@ -1 +1 @@ -Compress-Archive -Path "..\Oqtane.Server\bin\Release\net8.0\publish\*" -DestinationPath "Oqtane.Framework.5.2.2.Upgrade.zip" -Force +Compress-Archive -Path "..\Oqtane.Server\bin\Release\net9.0\publish\*" -DestinationPath "Oqtane.Framework.6.0.0.Upgrade.zip" -Force diff --git a/Oqtane.Server/Components/App.razor b/Oqtane.Server/Components/App.razor index 2277adea..e8c7ca76 100644 --- a/Oqtane.Server/Components/App.razor +++ b/Oqtane.Server/Components/App.razor @@ -179,10 +179,6 @@ ManageScripts(resources, alias); // generate scripts - if (_renderMode == RenderModes.Interactive && _runtime == Runtimes.Server) - { - _scripts += CreateReconnectScript(); - } if (site.PwaIsEnabled && site.PwaAppIconFileId != null && site.PwaSplashIconFileId != null) { _scripts += CreatePWAScript(alias, site, route); @@ -196,28 +192,29 @@ _bodyResources += ParseScripts(site.BodyContent); // set culture if not specified - string culture = Context.Request.Cookies[CookieRequestCultureProvider.DefaultCookieName]; - if (culture == null) + string cultureCookie = Context.Request.Cookies[Shared.CookieRequestCultureProvider.DefaultCookieName]; + if (cultureCookie == null) { // get default language for site if (site.Languages.Any()) { // use default language if specified otherwise use first language in collection - culture = (site.Languages.Where(l => l.IsDefault).SingleOrDefault() ?? site.Languages.First()).Code; + cultureCookie = (site.Languages.Where(l => l.IsDefault).SingleOrDefault() ?? site.Languages.First()).Code; } else { - culture = LocalizationManager.GetDefaultCulture(); + // fallback language + cultureCookie = LocalizationManager.GetDefaultCulture(); } - SetLocalizationCookie(culture); + // convert language code to culture cookie format (ie. "c=en|uic=en") + cultureCookie = Shared.CookieRequestCultureProvider.MakeCookieValue(new Models.RequestCulture(cultureCookie)); + SetLocalizationCookie(cultureCookie); } // set language for page - if (!string.IsNullOrEmpty(culture)) + if (!string.IsNullOrEmpty(cultureCookie)) { - // localization cookie value in form of c=en|uic=en - _language = culture.Split('|')[0]; - _language = _language.Replace("c=", ""); + _language = Shared.CookieRequestCultureProvider.ParseCookieValue(cultureCookie).Culture.Name; } // create initial PageState @@ -494,25 +491,6 @@ "" + Environment.NewLine; } - private string CreateReconnectScript() - { - return Environment.NewLine + - "" + Environment.NewLine; - } - private string CreateScrollPositionScript() { return Environment.NewLine + @@ -522,7 +500,7 @@ " let currentUrl = window.location.pathname;" + Environment.NewLine + " Blazor.addEventListener('enhancedload', () => {" + Environment.NewLine + " let newUrl = window.location.pathname;" + Environment.NewLine + - " if (currentUrl != newUrl) {" + Environment.NewLine + + " if (currentUrl !== newUrl || window.location.hash === '#top') {" + Environment.NewLine + " window.scrollTo({ top: 0, left: 0, behavior: 'instant' });" + Environment.NewLine + " }" + Environment.NewLine + " currentUrl = newUrl;" + Environment.NewLine + @@ -534,9 +512,9 @@ private string ParseScripts(string content) { - // iterate scripts var scripts = ""; - if (!string.IsNullOrEmpty(content)) + // in interactive render mode, parse scripts from content and inject into page + if (_renderMode == RenderModes.Interactive && !string.IsNullOrEmpty(content)) { var index = content.IndexOf("= 0) @@ -602,19 +580,19 @@ } } - private void SetLocalizationCookie(string culture) + private void SetLocalizationCookie(string cookieValue) { var cookieOptions = new Microsoft.AspNetCore.Http.CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1), SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax, // Set SameSite attribute Secure = true, // Ensure the cookie is only sent over HTTPS - HttpOnly = true // Optional: Helps mitigate XSS attacks + HttpOnly = false // cookie is updated using JS Interop in Interactive render mode }; Context.Response.Cookies.Append( - CookieRequestCultureProvider.DefaultCookieName, - CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)), + Shared.CookieRequestCultureProvider.DefaultCookieName, + cookieValue, cookieOptions ); } @@ -644,6 +622,16 @@ resources = AddResources(resources, obj.Resources, ResourceLevel.Page, alias, "Themes", type.Namespace, site.RenderMode); } } + // theme settings components are dynamically loaded within the framework Page Management module + if (page.Path == "admin/pages" && action.ToLower() == "edit" && theme != null && !string.IsNullOrEmpty(theme.ThemeSettingsType)) + { + var settingsType = Type.GetType(theme.ThemeSettingsType); + if (settingsType != null) + { + var objSettings = Activator.CreateInstance(settingsType) as IModuleControl; + resources = AddResources(resources, objSettings.Resources, ResourceLevel.Module, alias, "Modules", settingsType.Namespace, site.RenderMode); + } + } foreach (Module module in modules.Where(item => item.PageId == page.PageId || item.ModuleId == moduleid)) { @@ -686,25 +674,49 @@ // ensure component exists and implements IModuleControl module.ModuleType = ""; - Type moduletype = Type.GetType(typename, false, true); // case insensitive + var moduletype = Type.GetType(typename, false, true); // case insensitive if (moduletype != null && moduletype.GetInterfaces().Contains(typeof(IModuleControl))) { module.ModuleType = Utilities.GetFullTypeName(moduletype.AssemblyQualifiedName); // get actual type name } if (moduletype != null && module.ModuleType != "") { - var obj = Activator.CreateInstance(moduletype) as IModuleControl; - if (obj != null) + var moduleobject = Activator.CreateInstance(moduletype) as IModuleControl; + if (moduleobject != null) { - resources = AddResources(resources, obj.Resources, ResourceLevel.Module, alias, "Modules", moduletype.Namespace, site.RenderMode); + resources = AddResources(resources, moduleobject.Resources, ResourceLevel.Module, alias, "Modules", moduletype.Namespace, site.RenderMode); + + // settings components are dynamically loaded within the framework Settings module if (action.ToLower() == "settings" && module.ModuleDefinition != null) { - // settings components are embedded within a framework settings module - moduletype = Type.GetType(module.ModuleDefinition.ControlTypeTemplate.Replace(Constants.ActionToken, action), false, true); + // module settings component + var settingsType = ""; + if (!string.IsNullOrEmpty(module.ModuleDefinition.SettingsType)) + { + // module settings type explicitly declared in IModule interface + settingsType = module.ModuleDefinition.SettingsType; + } + else + { + // legacy support - module settings type determined by convention + settingsType = module.ModuleDefinition.ControlTypeTemplate.Replace(Constants.ActionToken, action); + } + moduletype = Type.GetType(settingsType, false, true); if (moduletype != null) { - obj = Activator.CreateInstance(moduletype) as IModuleControl; - resources = AddResources(resources, obj.Resources, ResourceLevel.Module, alias, "Modules", moduletype.Namespace, site.RenderMode); + moduleobject = Activator.CreateInstance(moduletype) as IModuleControl; + resources = AddResources(resources, moduleobject.Resources, ResourceLevel.Module, alias, "Modules", moduletype.Namespace, site.RenderMode); + } + + // container settings component + if (theme != null && !string.IsNullOrEmpty(theme.ContainerSettingsType)) + { + moduletype = Type.GetType(theme.ContainerSettingsType); + if (moduletype != null) + { + moduleobject = Activator.CreateInstance(moduletype) as IModuleControl; + resources = AddResources(resources, moduleobject.Resources, ResourceLevel.Module, alias, "Modules", moduletype.Namespace, site.RenderMode); + } } } } diff --git a/Oqtane.Server/Controllers/FileController.cs b/Oqtane.Server/Controllers/FileController.cs index 030005f5..70d17d1d 100644 --- a/Oqtane.Server/Controllers/FileController.cs +++ b/Oqtane.Server/Controllers/FileController.cs @@ -17,11 +17,10 @@ using Oqtane.Infrastructure; using Oqtane.Repository; using Oqtane.Extensions; using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; -using SixLabors.ImageSharp.Formats.Png; using System.Net.Http; using Microsoft.AspNetCore.Cors; using System.IO.Compression; +using Oqtane.Services; // ReSharper disable StringIndexOfIsCultureSpecific.1 @@ -38,7 +37,9 @@ namespace Oqtane.Controllers private readonly ILogManager _logger; private readonly Alias _alias; private readonly ISettingRepository _settingRepository; - public FileController(IWebHostEnvironment environment, IFileRepository files, IFolderRepository folders, IUserPermissions userPermissions, ISettingRepository settingRepository, ISyncManager syncManager, ILogManager logger, ITenantManager tenantManager) + private readonly IImageService _imageService; + + public FileController(IWebHostEnvironment environment, IFileRepository files, IFolderRepository folders, IUserPermissions userPermissions, ISettingRepository settingRepository, ISyncManager syncManager, ILogManager logger, ITenantManager tenantManager, IImageService imageService) { _environment = environment; _files = files; @@ -48,6 +49,7 @@ namespace Oqtane.Controllers _logger = logger; _alias = tenantManager.GetAlias(); _settingRepository = settingRepository; + _imageService = imageService; } // GET: api/?folder=x @@ -425,11 +427,11 @@ namespace Oqtane.Controllers // POST api//upload [EnableCors(Constants.MauiCorsPolicy)] [HttpPost("upload")] - public async Task UploadFile(string folder, IFormFile formfile) + public async Task UploadFile(string folder, IFormFile formfile) { if (formfile == null || formfile.Length <= 0) { - return; + return NoContent(); } // ensure filename is valid @@ -437,7 +439,7 @@ namespace Oqtane.Controllers if (!formfile.FileName.IsPathOrFileValid() || !formfile.FileName.Contains(token) || !HasValidFileExtension(formfile.FileName.Substring(0, formfile.FileName.IndexOf(token)))) { _logger.Log(LogLevel.Error, this, LogFunction.Security, "File Name Is Invalid Or Contains Invalid Extension {File}", formfile.FileName); - return; + return NoContent(); } string folderPath = ""; @@ -492,6 +494,8 @@ namespace Oqtane.Controllers _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Upload Attempt {Folder} {File}", folder, formfile.FileName); HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; } + + return NoContent(); } private async Task MergeFile(string folder, string filename) @@ -513,7 +517,7 @@ namespace Oqtane.Controllers bool success = true; using (var stream = new FileStream(Path.Combine(folder, filename + ".tmp"), FileMode.Create)) { - foreach (string filepart in fileparts) + foreach (string filepart in fileparts.Order()) { try { @@ -679,22 +683,18 @@ namespace Oqtane.Controllers var filepath = _files.GetFilePath(file); if (System.IO.File.Exists(filepath)) { - // validation - if (!Enum.TryParse(mode, true, out ResizeMode _)) mode = "crop"; - if (!Enum.TryParse(position, true, out AnchorPositionMode _)) position = "center"; - if (!Color.TryParseHex("#" + background, out _)) background = "transparent"; - if (!int.TryParse(rotate, out _)) rotate = "0"; - rotate = (int.Parse(rotate) < 0 || int.Parse(rotate) > 360) ? "0" : rotate; if (!bool.TryParse(recreate, out _)) recreate = "false"; - string imagepath = filepath.Replace(Path.GetExtension(filepath), "." + width.ToString() + "x" + height.ToString() + ".png"); + string format = "png"; + + string imagepath = filepath.Replace(Path.GetExtension(filepath), "." + width.ToString() + "x" + height.ToString() + "." + format); if (!System.IO.File.Exists(imagepath) || bool.Parse(recreate)) { // user has edit access to folder or folder supports the image size being created if (_userPermissions.IsAuthorized(User, PermissionNames.Edit, file.Folder.PermissionList) || (!string.IsNullOrEmpty(file.Folder.ImageSizes) && (file.Folder.ImageSizes == "*" || file.Folder.ImageSizes.ToLower().Split(",").Contains(width.ToString() + "x" + height.ToString())))) { - imagepath = CreateImage(filepath, width, height, mode, position, background, rotate, imagepath); + imagepath = _imageService.CreateImage(filepath, width, height, mode, position, background, rotate, format, imagepath); } else { @@ -741,70 +741,6 @@ namespace Oqtane.Controllers return System.IO.File.Exists(errorPath) ? PhysicalFile(errorPath, MimeUtilities.GetMimeType(errorPath)) : null; } - private string CreateImage(string filepath, int width, int height, string mode, string position, string background, string rotate, string imagepath) - { - try - { - using (var stream = new FileStream(filepath, FileMode.Open, FileAccess.Read)) - { - stream.Position = 0; - using (var image = Image.Load(stream)) - { - int.TryParse(rotate, out int angle); - Enum.TryParse(mode, true, out ResizeMode resizemode); - Enum.TryParse(position, true, out AnchorPositionMode anchorpositionmode); - - PngEncoder encoder; - - if (background != "transparent") - { - image.Mutate(x => x - .AutoOrient() // auto orient the image - .Rotate(angle) - .Resize(new ResizeOptions - { - Mode = resizemode, - Position = anchorpositionmode, - Size = new Size(width, height), - PadColor = Color.ParseHex("#" + background) - })); - - encoder = new PngEncoder(); - } - else - { - image.Mutate(x => x - .AutoOrient() // auto orient the image - .Rotate(angle) - .Resize(new ResizeOptions - { - Mode = resizemode, - Position = anchorpositionmode, - Size = new Size(width, height) - })); - - encoder = new PngEncoder - { - ColorType = PngColorType.RgbWithAlpha, - TransparentColorMode = PngTransparentColorMode.Preserve, - BitDepth = PngBitDepth.Bit8, - CompressionLevel = PngCompressionLevel.BestSpeed - }; - } - - image.Save(imagepath, encoder); - } - } - } - catch (Exception ex) - { - _logger.Log(LogLevel.Error, this, LogFunction.Security, ex, "Error Creating Image For File {FilePath} {Width} {Height} {Mode} {Rotate} {Error}", filepath, width, height, mode, rotate, ex.Message); - imagepath = ""; - } - - return imagepath; - } - private string GetFolderPath(string folder) { return Utilities.PathCombine(_environment.ContentRootPath, folder); diff --git a/Oqtane.Server/Controllers/LanguageController.cs b/Oqtane.Server/Controllers/LanguageController.cs index 6ee66cac..36750b83 100644 --- a/Oqtane.Server/Controllers/LanguageController.cs +++ b/Oqtane.Server/Controllers/LanguageController.cs @@ -55,6 +55,10 @@ namespace Oqtane.Controllers else { languages = _languages.GetLanguages(SiteId).ToList(); + foreach (Language language in languages) + { + language.Name = CultureInfo.GetCultureInfo(language.Code).DisplayName; + } if (!string.IsNullOrEmpty(packagename)) { foreach (var file in Directory.EnumerateFiles(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location), $"{packagename}*{Constants.SatelliteAssemblyExtension}", SearchOption.AllDirectories)) @@ -85,6 +89,7 @@ namespace Oqtane.Controllers var language = _languages.GetLanguage(id); if (language != null && language.SiteId == _alias.SiteId) { + language.Name = CultureInfo.GetCultureInfo(language.Code).DisplayName; return language; } else diff --git a/Oqtane.Server/Controllers/ModuleDefinitionController.cs b/Oqtane.Server/Controllers/ModuleDefinitionController.cs index a637be69..601d5ce8 100644 --- a/Oqtane.Server/Controllers/ModuleDefinitionController.cs +++ b/Oqtane.Server/Controllers/ModuleDefinitionController.cs @@ -351,9 +351,9 @@ namespace Oqtane.Controllers return new Dictionary() { { "FrameworkVersion", Constants.Version }, - { "ClientReference", $"..\\..\\{rootFolder}\\Oqtane.Server\\bin\\Debug\\net8.0\\Oqtane.Client.dll" }, - { "ServerReference", $"..\\..\\{rootFolder}\\Oqtane.Server\\bin\\Debug\\net8.0\\Oqtane.Server.dll" }, - { "SharedReference", $"..\\..\\{rootFolder}\\Oqtane.Server\\bin\\Debug\\net8.0\\Oqtane.Shared.dll" }, + { "ClientReference", $"..\\..\\{rootFolder}\\Oqtane.Server\\bin\\Debug\\net9.0\\Oqtane.Client.dll" }, + { "ServerReference", $"..\\..\\{rootFolder}\\Oqtane.Server\\bin\\Debug\\net9.0\\Oqtane.Server.dll" }, + { "SharedReference", $"..\\..\\{rootFolder}\\Oqtane.Server\\bin\\Debug\\net9.0\\Oqtane.Shared.dll" }, }; }); } diff --git a/Oqtane.Server/Controllers/NotificationController.cs b/Oqtane.Server/Controllers/NotificationController.cs index 44bc7a93..5f7ee353 100644 --- a/Oqtane.Server/Controllers/NotificationController.cs +++ b/Oqtane.Server/Controllers/NotificationController.cs @@ -8,10 +8,6 @@ using Oqtane.Infrastructure; using Oqtane.Repository; using Oqtane.Security; using System.Net; -using System.Reflection.Metadata; -using Microsoft.Extensions.Localization; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using System.Linq; namespace Oqtane.Controllers { diff --git a/Oqtane.Server/Controllers/SettingController.cs b/Oqtane.Server/Controllers/SettingController.cs index 30a01330..d8a95cbe 100644 --- a/Oqtane.Server/Controllers/SettingController.cs +++ b/Oqtane.Server/Controllers/SettingController.cs @@ -189,7 +189,7 @@ namespace Oqtane.Controllers public void Delete(string entityName, int entityId, string settingName) { Setting setting = _settings.GetSetting(entityName, entityId, settingName); - if (IsAuthorized(setting.EntityName, setting.EntityId, PermissionNames.Edit)) + if (setting != null && IsAuthorized(setting.EntityName, setting.EntityId, PermissionNames.Edit)) { _settings.DeleteSetting(setting.EntityName, setting.SettingId); AddSyncEvent(setting.EntityName, setting.EntityId, setting.SettingId, SyncEventActions.Delete); @@ -199,7 +199,7 @@ namespace Oqtane.Controllers { if (entityName != EntityNames.Visitor) { - _logger.Log(LogLevel.Error, this, LogFunction.Delete, "User Not Authorized To Delete Setting {Setting}", setting); + _logger.Log(LogLevel.Error, this, LogFunction.Delete, "Setting Does Not Exist Or User Not Authorized To Delete Setting For Entity {EntityName} Id {EntityId} Name {SettingName}", entityName, entityId, settingName); HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; } } diff --git a/Oqtane.Server/Controllers/ThemeController.cs b/Oqtane.Server/Controllers/ThemeController.cs index 1eeee244..f42fc01b 100644 --- a/Oqtane.Server/Controllers/ThemeController.cs +++ b/Oqtane.Server/Controllers/ThemeController.cs @@ -267,8 +267,8 @@ namespace Oqtane.Controllers return new Dictionary() { { "FrameworkVersion", Constants.Version }, - { "ClientReference", $"..\\..\\{rootFolder}\\Oqtane.Server\\bin\\Debug\\net8.0\\Oqtane.Client.dll" }, - { "SharedReference", $"..\\..\\{rootFolder}\\Oqtane.Server\\bin\\Debug\\net8.0\\Oqtane.Shared.dll" }, + { "ClientReference", $"..\\..\\{rootFolder}\\Oqtane.Server\\bin\\Debug\\net9.0\\Oqtane.Client.dll" }, + { "SharedReference", $"..\\..\\{rootFolder}\\Oqtane.Server\\bin\\Debug\\net9.0\\Oqtane.Shared.dll" }, }; }); } diff --git a/Oqtane.Server/Controllers/UserController.cs b/Oqtane.Server/Controllers/UserController.cs index b17efbc8..acbcb1a1 100644 --- a/Oqtane.Server/Controllers/UserController.cs +++ b/Oqtane.Server/Controllers/UserController.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using System.Linq; using System.Security.Claims; using Oqtane.Shared; -using System; using System.Net; using Oqtane.Enums; using Oqtane.Infrastructure; @@ -120,11 +119,15 @@ namespace Oqtane.Controllers filtered = new User(); // public properties + filtered.SiteId = user.SiteId; filtered.UserId = user.UserId; filtered.Username = user.Username; filtered.DisplayName = user.DisplayName; + + // restricted properties filtered.Password = ""; filtered.TwoFactorCode = ""; + filtered.SecurityStamp = ""; // include private properties if authenticated user is accessing their own user account os is an administrator if (_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || _userPermissions.GetUser(User).UserId == user.UserId) @@ -260,8 +263,24 @@ namespace Oqtane.Controllers [Authorize] public async Task Logout([FromBody] User user) { - await HttpContext.SignOutAsync(Constants.AuthenticationScheme); - _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Logout {Username}", (user != null) ? user.Username : ""); + if (_userPermissions.GetUser(User).UserId == user.UserId) + { + await HttpContext.SignOutAsync(Constants.AuthenticationScheme); + _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Logout {Username}", (user != null) ? user.Username : ""); + } + } + + // POST api//logout + [HttpPost("logouteverywhere")] + [Authorize] + public async Task LogoutEverywhere([FromBody] User user) + { + if (_userPermissions.GetUser(User).UserId == user.UserId) + { + await _userManager.LogoutUserEverywhere(user); + await HttpContext.SignOutAsync(Constants.AuthenticationScheme); + _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Logout Everywhere {Username}", (user != null) ? user.Username : ""); + } } // POST api//verify @@ -328,6 +347,13 @@ namespace Oqtane.Controllers return user; } + // GET api//validate/x + [HttpGet("validateuser")] + public async Task ValidateUser(string username, string email, string password) + { + return await _userManager.ValidateUser(username, email, password); + } + // GET api//validate/x [HttpGet("validate/{password}")] public async Task Validate(string password) @@ -382,6 +408,7 @@ namespace Oqtane.Controllers } if (roles != "") roles = ";" + roles; user.Roles = roles; + user.SecurityStamp = User.SecurityStamp(); } return user; } diff --git a/Oqtane.Server/Databases/Interfaces/IMultiDatabase.cs b/Oqtane.Server/Databases/Interfaces/IMultiDatabase.cs index 01d926f5..033678d1 100644 --- a/Oqtane.Server/Databases/Interfaces/IMultiDatabase.cs +++ b/Oqtane.Server/Databases/Interfaces/IMultiDatabase.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using Oqtane.Databases.Interfaces; -using Oqtane.Interfaces; namespace Oqtane.Repository.Databases.Interfaces { diff --git a/Oqtane.Server/Extensions/ClaimsPrincipalExtensions.cs b/Oqtane.Server/Extensions/ClaimsPrincipalExtensions.cs index bc5c94bd..1749c421 100644 --- a/Oqtane.Server/Extensions/ClaimsPrincipalExtensions.cs +++ b/Oqtane.Server/Extensions/ClaimsPrincipalExtensions.cs @@ -1,6 +1,5 @@ using System.Linq; using System.Security.Claims; -using Oqtane.Models; using Oqtane.Shared; namespace Oqtane.Extensions @@ -41,9 +40,9 @@ namespace Oqtane.Extensions public static string SiteKey(this ClaimsPrincipal claimsPrincipal) { - if (claimsPrincipal.HasClaim(item => item.Type == "sitekey")) + if (claimsPrincipal.HasClaim(item => item.Type == Constants.SiteKeyClaimType)) { - return claimsPrincipal.Claims.FirstOrDefault(item => item.Type == "sitekey").Value; + return claimsPrincipal.Claims.FirstOrDefault(item => item.Type == Constants.SiteKeyClaimType).Value; } else { @@ -71,6 +70,18 @@ namespace Oqtane.Extensions return -1; } + public static string SecurityStamp(this ClaimsPrincipal claimsPrincipal) + { + if (claimsPrincipal.HasClaim(item => item.Type == Constants.SecurityStampClaimType)) + { + return claimsPrincipal.Claims.FirstOrDefault(item => item.Type == Constants.SecurityStampClaimType).Value; + } + else + { + return ""; + } + } + public static bool IsOnlyInRole(this ClaimsPrincipal claimsPrincipal, string role) { var identity = claimsPrincipal.Identities.FirstOrDefault(item => item.AuthenticationType == Constants.AuthenticationScheme); diff --git a/Oqtane.Server/Extensions/DbContextOptionsBuilderExtensions.cs b/Oqtane.Server/Extensions/DbContextOptionsBuilderExtensions.cs index a35ecf77..2134708d 100644 --- a/Oqtane.Server/Extensions/DbContextOptionsBuilderExtensions.cs +++ b/Oqtane.Server/Extensions/DbContextOptionsBuilderExtensions.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Oqtane.Databases.Interfaces; // ReSharper disable ConvertToUsingDeclaration @@ -9,7 +10,8 @@ namespace Oqtane.Extensions { public static DbContextOptionsBuilder UseOqtaneDatabase([NotNull] this DbContextOptionsBuilder optionsBuilder, IDatabase database, string connectionString) { - database.UseDatabase(optionsBuilder, connectionString); + database.UseDatabase(optionsBuilder, connectionString) + .ConfigureWarnings(warnings => warnings.Log(RelationalEventId.PendingModelChangesWarning)); return optionsBuilder; } diff --git a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs index d9b31fd9..b04f2061 100644 --- a/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs +++ b/Oqtane.Server/Extensions/OqtaneServiceCollectionExtensions.cs @@ -102,6 +102,7 @@ namespace Microsoft.Extensions.DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // providers services.AddScoped(); @@ -112,8 +113,11 @@ namespace Microsoft.Extensions.DependencyInjection internal static IServiceCollection AddOqtaneTransientServices(this IServiceCollection services) { - // repositories + // services services.AddTransient(); + services.AddTransient(); + + // repositories services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -129,7 +133,6 @@ namespace Microsoft.Extensions.DependencyInjection services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -152,12 +155,12 @@ namespace Microsoft.Extensions.DependencyInjection services.AddTransient(); services.AddTransient(); services.AddTransient(); - - // obsolete - replaced by ITenantManager - services.AddTransient(); - + services.AddTransient(); services.AddTransient(); + // obsolete + services.AddTransient(); // replaced by ITenantManager + return services; } @@ -169,6 +172,7 @@ namespace Microsoft.Extensions.DependencyInjection options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; + options.LoginPath = "/login"; // overrides .NET Identity default of /Account/Login options.Events.OnRedirectToLogin = context => { context.Response.StatusCode = (int)HttpStatusCode.Forbidden; diff --git a/Oqtane.Server/Extensions/OqtaneSiteAuthenticationBuilderExtensions.cs b/Oqtane.Server/Extensions/OqtaneSiteAuthenticationBuilderExtensions.cs index 604270c1..cb0303e6 100644 --- a/Oqtane.Server/Extensions/OqtaneSiteAuthenticationBuilderExtensions.cs +++ b/Oqtane.Server/Extensions/OqtaneSiteAuthenticationBuilderExtensions.cs @@ -527,28 +527,63 @@ namespace Oqtane.Extensions // manage user if (user != null) { - // create claims identity - var _userRoles = httpContext.RequestServices.GetRequiredService(); - identity = UserSecurity.CreateClaimsIdentity(alias, user, _userRoles.GetUserRoles(user.UserId, user.SiteId).ToList()); - identity.Label = ExternalLoginStatus.Success; - // update user user.LastLoginOn = DateTime.UtcNow; user.LastIPAddress = httpContext.Connection.RemoteIpAddress.ToString(); _users.UpdateUser(user); - // external roles + // manage roles + var _userRoles = httpContext.RequestServices.GetRequiredService(); + var userRoles = _userRoles.GetUserRoles(user.UserId, user.SiteId).ToList(); if (!string.IsNullOrEmpty(httpContext.GetSiteSettings().GetValue("ExternalLogin:RoleClaimType", ""))) { - if (claimsPrincipal.Claims.Any(item => item.Type == ClaimTypes.Role)) + // external roles + if (claimsPrincipal.Claims.Any(item => item.Type == httpContext.GetSiteSettings().GetValue("ExternalLogin:RoleClaimType", ""))) { - foreach (var claim in claimsPrincipal.Claims.Where(item => item.Type == ClaimTypes.Role)) + var _roles = httpContext.RequestServices.GetRequiredService(); + var roles = _roles.GetRoles(user.SiteId).ToList(); // global roles excluded ie. host users cannot be added/deleted + + var mappings = httpContext.GetSiteSettings().GetValue("ExternalLogin:RoleClaimMappings", "").Split(','); + foreach (var claim in claimsPrincipal.Claims.Where(item => item.Type == httpContext.GetSiteSettings().GetValue("ExternalLogin:RoleClaimType", ""))) { - if (!identity.Claims.Any(item => item.Type == ClaimTypes.Role && item.Value == claim.Value)) + var rolename = claim.Value; + if (mappings.Any(item => item.StartsWith(rolename + ":"))) { - identity.AddClaim(new Claim(ClaimTypes.Role, claim.Value)); + rolename = mappings.First(item => item.StartsWith(rolename + ":")).Split(':')[1]; + } + var role = roles.FirstOrDefault(item => item.Name == rolename); + if (role != null) + { + if (!userRoles.Any(item => item.RoleId == role.RoleId && item.UserId == user.UserId)) + { + var userRole = new UserRole(); + userRole.RoleId = role.RoleId; + userRole.UserId = user.UserId; + _userRoles.AddUserRole(userRole); + } } } + if (bool.Parse(httpContext.GetSiteSettings().GetValue("ExternalLogin:SynchronizeRoles", "false"))) + { + userRoles = _userRoles.GetUserRoles(user.UserId, user.SiteId).ToList(); + foreach (var userRole in userRoles) + { + var role = roles.FirstOrDefault(item => item.RoleId == userRole.RoleId); + if (role != null) + { + var rolename = role.Name; + if (mappings.Any(item => item.EndsWith(":" + rolename))) + { + rolename = mappings.First(item => item.EndsWith(":" + rolename)).Split(':')[0]; + } + if (!claimsPrincipal.Claims.Any(item => item.Type == httpContext.GetSiteSettings().GetValue("ExternalLogin:RoleClaimType", "") && item.Value == rolename)) + { + _userRoles.DeleteUserRole(userRole.UserRoleId); + } + } + } + } + userRoles = _userRoles.GetUserRoles(user.UserId, user.SiteId).ToList(); } else { @@ -556,6 +591,12 @@ namespace Oqtane.Extensions } } + // create claims identity + identityuser = await _identityUserManager.FindByNameAsync(user.Username); + user.SecurityStamp = identityuser.SecurityStamp; + identity = UserSecurity.CreateClaimsIdentity(alias, user, userRoles); + identity.Label = ExternalLoginStatus.Success; + // user profile claims if (!string.IsNullOrEmpty(httpContext.GetSiteSettings().GetValue("ExternalLogin:ProfileClaimTypes", ""))) { @@ -604,13 +645,13 @@ namespace Oqtane.Extensions } } - _logger.Log(LogLevel.Information, "ExternalLogin", Enums.LogFunction.Security, "External User Login Successful For {Username} Using Provider {Provider}", user.Username, providerName); + _logger.Log(LogLevel.Information, "ExternalLogin", Enums.LogFunction.Security, "External User Login Successful For {Username} From IP Address {IPAddress} Using Provider {Provider}", user.Username, httpContext.Connection.RemoteIpAddress.ToString(), providerName); } } else // claims invalid { identity.Label = ExternalLoginStatus.MissingClaims; - _logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "Provider Did Not Return All Of The Claims Types Specified Or Email Address Does Not Saitisfy Domain Filter. The Actual Claims Returned Were {Claims}. Login Was Denied.", claims); + _logger.Log(LogLevel.Error, "ExternalLogin", Enums.LogFunction.Security, "Provider Did Not Return All Of The Claims Types Specified Or Email Address Does Not Satisfy Domain Filter. The Actual Claims Returned Were {Claims}. Login Was Denied.", claims); } return identity; diff --git a/Oqtane.Server/Infrastructure/DatabaseManager.cs b/Oqtane.Server/Infrastructure/DatabaseManager.cs index 3d9be5de..30593b3b 100644 --- a/Oqtane.Server/Infrastructure/DatabaseManager.cs +++ b/Oqtane.Server/Infrastructure/DatabaseManager.cs @@ -155,7 +155,7 @@ namespace Oqtane.Infrastructure // add new site if (install.TenantName != TenantNames.Master && install.ConnectionString.Contains("=")) { - _configManager.AddOrUpdateSetting($"{SettingKeys.ConnectionStringsSection}:{install.TenantName}", install.ConnectionString, false); + _configManager.AddOrUpdateSetting($"{SettingKeys.ConnectionStringsSection}:{install.TenantName}", install.ConnectionString, true); } if (install.TenantName == TenantNames.Master && !install.ConnectionString.Contains("=")) { @@ -375,7 +375,6 @@ namespace Oqtane.Infrastructure AddEFMigrationsHistory(sql, _configManager.GetSetting($"{SettingKeys.ConnectionStringsSection}:{tenant.DBConnectionString}", ""), tenant.DBType, tenant.Version, false); // push latest model into database tenantDbContext.Database.Migrate(); - result.Success = true; } } catch (Exception ex) @@ -384,35 +383,35 @@ namespace Oqtane.Infrastructure _filelogger.LogError(Utilities.LogMessage(this, result.Message)); } - // execute any version specific upgrade logic - var version = tenant.Version; - var index = Array.FindIndex(versions, item => item == version); - if (index != (versions.Length - 1)) + if (string.IsNullOrEmpty(result.Message)) { - try + // execute any version specific upgrade logic + var version = tenant.Version; + var index = Array.FindIndex(versions, item => item == version); + if (index != (versions.Length - 1)) { - for (var i = (index + 1); i < versions.Length; i++) + try { - upgrades.Upgrade(tenant, versions[i]); + for (var i = (index + 1); i < versions.Length; i++) + { + upgrades.Upgrade(tenant, versions[i]); + } + tenant.Version = versions[versions.Length - 1]; + db.Entry(tenant).State = EntityState.Modified; + db.SaveChanges(); + } + catch (Exception ex) + { + result.Message = "An Error Occurred Executing Upgrade Logic On Tenant " + tenant.Name + ". " + ex.ToString(); + _filelogger.LogError(Utilities.LogMessage(this, result.Message)); } - tenant.Version = versions[versions.Length - 1]; - db.Entry(tenant).State = EntityState.Modified; - db.SaveChanges(); - } - catch (Exception ex) - { - result.Message = "An Error Occurred Executing Upgrade Logic On Tenant " + tenant.Name + ". " + ex.ToString(); - _filelogger.LogError(Utilities.LogMessage(this, result.Message)); } } } } } - if (string.IsNullOrEmpty(result.Message)) - { - result.Success = true; - } + result.Success = string.IsNullOrEmpty(result.Message); return result; } @@ -588,7 +587,7 @@ namespace Oqtane.Infrastructure // add host role var hostRoleId = roles.GetRoles(user.SiteId, true).FirstOrDefault(item => item.Name == RoleNames.Host)?.RoleId ?? 0; - var userRole = new UserRole { UserId = user.UserId, RoleId = hostRoleId, EffectiveDate = null, ExpiryDate = null }; + var userRole = new UserRole { UserId = user.UserId, RoleId = hostRoleId, EffectiveDate = null, ExpiryDate = null, IgnoreSecurityStamp = true }; userRoles.AddUserRole(userRole); } } diff --git a/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs b/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs index b47c5732..ab9cc058 100644 --- a/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs +++ b/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs @@ -89,9 +89,9 @@ namespace Oqtane.Infrastructure } // validate recipient - if (string.IsNullOrEmpty(notification.ToEmail)) + if (string.IsNullOrEmpty(notification.ToEmail) || !MailAddress.TryCreate(notification.ToEmail, out _)) { - log += "Recipient Missing For NotificationId: " + notification.NotificationId + "
"; + log += $"NotificationId: {notification.NotificationId} - Has Missing Or Invalid Recipient {notification.ToEmail}
"; notification.IsDeleted = true; notificationRepository.UpdateNotification(notification); } diff --git a/Oqtane.Server/Infrastructure/Jobs/SearchIndexJob.cs b/Oqtane.Server/Infrastructure/Jobs/SearchIndexJob.cs index 21b28e40..9b2d2f83 100644 --- a/Oqtane.Server/Infrastructure/Jobs/SearchIndexJob.cs +++ b/Oqtane.Server/Infrastructure/Jobs/SearchIndexJob.cs @@ -59,8 +59,15 @@ namespace Oqtane.Infrastructure var currentTime = DateTime.UtcNow; var lastIndexedOn = Convert.ToDateTime(siteSettings.GetValue(SearchLastIndexedOnSetting, DateTime.MinValue.ToString())); + if (lastIndexedOn == DateTime.MinValue) + { + // reset index + log += $"*Site Index Reset*
"; + await searchService.DeleteSearchContentsAsync(site.SiteId); + } + var ignorePages = siteSettings.GetValue(SearchIgnorePagesSetting, "").Split(','); - var ignoreEntities = siteSettings.GetValue(SearchIgnoreEntitiesSetting, "").Split(','); + var ignoreEntities = siteSettings.GetValue(SearchIgnoreEntitiesSetting, "File").Split(','); var pages = pageRepository.GetPages(site.SiteId); var pageModules = pageModuleRepository.GetPageModules(site.SiteId); diff --git a/Oqtane.Server/Infrastructure/LogManager.cs b/Oqtane.Server/Infrastructure/LogManager.cs index 16e08a78..91f128ee 100644 --- a/Oqtane.Server/Infrastructure/LogManager.cs +++ b/Oqtane.Server/Infrastructure/LogManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Oqtane.Enums; using Oqtane.Models; using Oqtane.Repository; @@ -20,8 +21,9 @@ namespace Oqtane.Infrastructure private readonly IHttpContextAccessor _accessor; private readonly IUserRoleRepository _userRoles; private readonly INotificationRepository _notifications; + private readonly ILogger _filelogger; - public LogManager(ILogRepository logs, ITenantManager tenantManager, IConfigManager config, IUserPermissions userPermissions, IHttpContextAccessor accessor, IUserRoleRepository userRoles, INotificationRepository notifications) + public LogManager(ILogRepository logs, ITenantManager tenantManager, IConfigManager config, IUserPermissions userPermissions, IHttpContextAccessor accessor, IUserRoleRepository userRoles, INotificationRepository notifications, ILogger filelogger) { _logs = logs; _tenantManager = tenantManager; @@ -30,24 +32,25 @@ namespace Oqtane.Infrastructure _accessor = accessor; _userRoles = userRoles; _notifications = notifications; + _filelogger = filelogger; } - public void Log(LogLevel level, object @class, LogFunction function, string message, params object[] args) + public void Log(Shared.LogLevel level, object @class, LogFunction function, string message, params object[] args) { Log(-1, level, @class, function, null, message, args); } - public void Log(LogLevel level, object @class, LogFunction function, Exception exception, string message, params object[] args) + public void Log(Shared.LogLevel level, object @class, LogFunction function, Exception exception, string message, params object[] args) { Log(-1, level, @class, function, exception, message, args); } - public void Log(int siteId, LogLevel level, object @class, LogFunction function, string message, params object[] args) + public void Log(int siteId, Shared.LogLevel level, object @class, LogFunction function, string message, params object[] args) { Log(siteId, level, @class, function, null, message, args); } - public void Log(int siteId, LogLevel level, object @class, LogFunction function, Exception exception, string message, params object[] args) + public void Log(int siteId, Shared.LogLevel level, object @class, LogFunction function, Exception exception, string message, params object[] args) { Log log = new Log(); @@ -60,7 +63,6 @@ namespace Oqtane.Infrastructure log.SiteId = alias.SiteId; } } - if (log.SiteId == -1) return; // logs must be site specific log.PageId = null; log.ModuleId = null; @@ -92,7 +94,7 @@ namespace Oqtane.Infrastructure log.Feature = log.Category; } log.Function = Enum.GetName(typeof(LogFunction), function); - log.Level = Enum.GetName(typeof(LogLevel), level); + log.Level = Enum.GetName(typeof(Shared.LogLevel), level); if (exception != null) { log.Exception = exception.ToString(); @@ -112,14 +114,14 @@ namespace Oqtane.Infrastructure public void Log(Log log) { - LogLevel minlevel = LogLevel.Information; + var minlevel = Shared.LogLevel.Information; var section = _config.GetSection("Logging:LogLevel:Default"); if (section.Exists()) { - minlevel = Enum.Parse(section.Value); + minlevel = Enum.Parse(section.Value); } - if (Enum.Parse(log.Level) >= minlevel) + if (Enum.Parse(log.Level) >= minlevel) { log.LogDate = DateTime.UtcNow; log.Server = Environment.MachineName; @@ -127,12 +129,19 @@ namespace Oqtane.Infrastructure log = ProcessStructuredLog(log); try { - _logs.AddLog(log); - SendNotification(log); + if (log.SiteId != -1) + { + _logs.AddLog(log); + SendNotification(log); + } + else // use file logger as fallback when site cannot be determined + { + _filelogger.Log(GetLogLevel(log.Level), "[" + log.Category + "] " + log.Message); + } } catch { - // an error occurred writing to the database + // an error occurred writing the log } } } @@ -156,17 +165,11 @@ namespace Oqtane.Infrastructure names.Add(message.Substring(index + 1, message.IndexOf("}", index) - index - 1)); if (values.Length > (names.Count - 1)) { - if (values[names.Count - 1] == null) - { - message = message.Replace("{" + names[names.Count - 1] + "}", "null"); - } - else - { - message = message.Replace("{" + names[names.Count - 1] + "}", values[names.Count - 1].ToString()); - } + var value = (values[names.Count - 1] == null) ? "null" : values[names.Count - 1].ToString(); + message = message.Replace("{" + names[names.Count - 1] + "}", value); } } - index = message.IndexOf("{", index + 1); + index = (index < message.Length - 1) ? message.IndexOf("{", index + 1) : -1; } // rebuild properties into dictionary Dictionary propertyDictionary = new Dictionary(); @@ -195,13 +198,13 @@ namespace Oqtane.Infrastructure private void SendNotification(Log log) { - LogLevel notifylevel = LogLevel.Error; + Shared.LogLevel notifylevel = Shared.LogLevel.Error; var section = _config.GetSection("Logging:LogLevel:Notify"); if (section.Exists()) { - notifylevel = Enum.Parse(section.Value); + notifylevel = Enum.Parse(section.Value); } - if (Enum.Parse(log.Level) >= notifylevel) + if (Enum.Parse(log.Level) >= notifylevel) { var subject = $"Site {log.Level} Notification"; string body = $"Log Message: {log.Message}"; @@ -220,5 +223,26 @@ namespace Oqtane.Infrastructure } } } + + private Microsoft.Extensions.Logging.LogLevel GetLogLevel(string level) + { + switch (Enum.Parse(level)) + { + case Shared.LogLevel.Trace: + return Microsoft.Extensions.Logging.LogLevel.Trace; + case Shared.LogLevel.Debug: + return Microsoft.Extensions.Logging.LogLevel.Debug; + case Shared.LogLevel.Information: + return Microsoft.Extensions.Logging.LogLevel.Information; + case Shared.LogLevel.Warning: + return Microsoft.Extensions.Logging.LogLevel.Warning; + case Shared.LogLevel.Error: + return Microsoft.Extensions.Logging.LogLevel.Error; + case Shared.LogLevel.Critical: + return Microsoft.Extensions.Logging.LogLevel.Critical; + default: + return Microsoft.Extensions.Logging.LogLevel.None; + } + } } } diff --git a/Oqtane.Server/Infrastructure/Middleware/JwtMiddleware.cs b/Oqtane.Server/Infrastructure/Middleware/JwtMiddleware.cs index 7b8696fa..071828d5 100644 --- a/Oqtane.Server/Infrastructure/Middleware/JwtMiddleware.cs +++ b/Oqtane.Server/Infrastructure/Middleware/JwtMiddleware.cs @@ -3,8 +3,7 @@ using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Oqtane.Extensions; -using Oqtane.Models; -using Oqtane.Repository; +using Oqtane.Managers; using Oqtane.Security; using Oqtane.Shared; @@ -59,19 +58,18 @@ namespace Oqtane.Infrastructure if (userid != null && username != null) { - // create user identity - var user = new User + var _users = context.RequestServices.GetService(typeof(IUserManager)) as IUserManager; + var user = _users.GetUser(userid, alias.SiteId); // cached + if (user != null && !user.IsDeleted) { - UserId = int.Parse(userid), - Username = username - }; - - // set claims identity (note jwt already contains the roles - we are reloading to ensure most accurate permissions) - var _userRoles = context.RequestServices.GetService(typeof(IUserRoleRepository)) as IUserRoleRepository; - var claimsidentity = UserSecurity.CreateClaimsIdentity(alias, user, _userRoles.GetUserRoles(user.UserId, alias.SiteId).ToList()); - context.User = new ClaimsPrincipal(claimsidentity); - - logger.Log(alias.SiteId, LogLevel.Information, "TokenValidation", Enums.LogFunction.Security, "Token Validated For UserId {UserId} And Username {Username}", user.UserId, user.Username); + var claimsidentity = UserSecurity.CreateClaimsIdentity(alias, user); + context.User = new ClaimsPrincipal(claimsidentity); + logger.Log(alias.SiteId, LogLevel.Information, "TokenValidation", Enums.LogFunction.Security, "Token Validated For User {Username}", user.Username); + } + else + { + logger.Log(alias.SiteId, LogLevel.Error, "TokenValidation", Enums.LogFunction.Security, "Token Validated But User {Username} Does Not Exist Or Is Deleted", user.Username); + } } else { diff --git a/Oqtane.Server/Managers/Interfaces/IUserManager.cs b/Oqtane.Server/Managers/Interfaces/IUserManager.cs index afcf8a0a..4fde062c 100644 --- a/Oqtane.Server/Managers/Interfaces/IUserManager.cs +++ b/Oqtane.Server/Managers/Interfaces/IUserManager.cs @@ -13,11 +13,13 @@ namespace Oqtane.Managers Task UpdateUser(User user); Task DeleteUser(int userid, int siteid); Task LoginUser(User user, bool setCookie, bool isPersistent); + Task LogoutUserEverywhere(User user); Task VerifyEmail(User user, string token); Task ForgotPassword(User user); Task ResetPassword(User user, string token); User VerifyTwoFactor(User user, string token); Task LinkExternalAccount(User user, string token, string type, string key, string name); + Task ValidateUser(string username, string email, string password); Task ValidatePassword(string password); Task> ImportUsers(int siteId, string filePath, bool notify); } diff --git a/Oqtane.Server/Managers/UserManager.cs b/Oqtane.Server/Managers/UserManager.cs index 6de9c9d3..03bff1bb 100644 --- a/Oqtane.Server/Managers/UserManager.cs +++ b/Oqtane.Server/Managers/UserManager.cs @@ -64,6 +64,7 @@ namespace Oqtane.Managers { user.SiteId = siteid; user.Roles = GetUserRoles(user.UserId, user.SiteId); + user.SecurityStamp = _identityUserManager.FindByNameAsync(user.Username).GetAwaiter().GetResult()?.SecurityStamp; user.Settings = _settings.GetSettings(EntityNames.User, user.UserId) .ToDictionary(setting => setting.SettingName, setting => setting.SettingValue); } @@ -230,6 +231,7 @@ namespace Oqtane.Managers { identityuser.PasswordHash = _identityUserManager.PasswordHasher.HashPassword(identityuser, user.Password); await _identityUserManager.UpdateAsync(identityuser); + await _identityUserManager.UpdateSecurityStampAsync(identityuser); // will force user to sign in again } else { @@ -240,7 +242,8 @@ namespace Oqtane.Managers if (user.Email != identityuser.Email) { - await _identityUserManager.SetEmailAsync(identityuser, user.Email); + identityuser.Email = user.Email; + await _identityUserManager.UpdateAsync(identityuser); // security stamp not updated // if email address changed and it is not confirmed, verification is required for new email address if (!user.EmailConfirmed) @@ -262,7 +265,6 @@ namespace Oqtane.Managers user = _users.UpdateUser(user); _syncManager.AddSyncEvent(_tenantManager.GetAlias(), EntityNames.User, user.UserId, SyncEventActions.Update); _syncManager.AddSyncEvent(_tenantManager.GetAlias(), EntityNames.User, user.UserId, SyncEventActions.Reload); - _cache.Remove($"user:{user.UserId}:{alias.SiteKey}"); user.Password = ""; // remove sensitive information _logger.Log(LogLevel.Information, this, LogFunction.Update, "User Updated {User}", user); } @@ -370,7 +372,7 @@ namespace Oqtane.Managers user.LastLoginOn = DateTime.UtcNow; user.LastIPAddress = LastIPAddress; _users.UpdateUser(user); - _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Login Successful {Username}", user.Username); + _logger.Log(LogLevel.Information, this, LogFunction.Security, "User Login Successful For {Username} From IP Address {IPAddress}", user.Username, LastIPAddress); if (setCookie) { @@ -417,6 +419,16 @@ namespace Oqtane.Managers return user; } + public async Task LogoutUserEverywhere(User user) + { + var identityuser = await _identityUserManager.FindByNameAsync(user.Username); + if (identityuser != null) + { + await _identityUserManager.UpdateSecurityStampAsync(identityuser); + _syncManager.AddSyncEvent(_tenantManager.GetAlias(), EntityNames.User, user.UserId, SyncEventActions.Update); + _syncManager.AddSyncEvent(_tenantManager.GetAlias(), EntityNames.User, user.UserId, SyncEventActions.Reload); + } + } public async Task VerifyEmail(User user, string token) { @@ -528,6 +540,30 @@ namespace Oqtane.Managers return user; } + public async Task ValidateUser(string username, string email, string password) + { + var validateResult = new UserValidateResult { Succeeded = true }; + + //validate username + var allowedChars = _identityUserManager.Options.User.AllowedUserNameCharacters; + if (string.IsNullOrWhiteSpace(username) || (!string.IsNullOrEmpty(allowedChars) && username.Any(c => !allowedChars.Contains(c)))) + { + validateResult.Succeeded = false; + validateResult.Errors.Add("Message.Username.Invalid", string.Empty); + } + + //validate password + var passwordValidator = new PasswordValidator(); + var passwordResult = await passwordValidator.ValidateAsync(_identityUserManager, null, password); + if (!passwordResult.Succeeded) + { + validateResult.Succeeded = false; + validateResult.Errors.Add("Message.Password.Invalid", string.Empty); + } + + return validateResult; + } + public async Task ValidatePassword(string password) { var validator = new PasswordValidator(); diff --git a/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs b/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs index 8f2cb20a..cfb58b72 100644 --- a/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs +++ b/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs @@ -319,6 +319,19 @@ namespace Oqtane.Migrations.EntityBuilders schema: Schema); } + public virtual void AddForeignKey(string foreignKeyName, string columnName, string principalTable, string principalColumn, string principalSchema, ReferentialAction onDelete) + { + _migrationBuilder.AddForeignKey( + name: RewriteName(foreignKeyName), + table: RewriteName(EntityTableName), + column: RewriteName(columnName), + principalTable: RewriteName(principalTable), + principalColumn: RewriteName(principalColumn), + principalSchema: RewriteName(principalSchema), + onDelete: onDelete, + schema: Schema); + } + /// /// Creates a Migration to add an Index to the Entity (table) /// @@ -368,6 +381,7 @@ namespace Oqtane.Migrations.EntityBuilders column: foreignKey.Column, principalTable: RewriteName(foreignKey.PrincipalTable), principalColumn: RewriteName(foreignKey.PrincipalColumn), + principalSchema: RewriteName(foreignKey.PrincipalSchema), onDelete: foreignKey.OnDeleteAction); } @@ -381,6 +395,7 @@ namespace Oqtane.Migrations.EntityBuilders column: RewriteName(foreignKey.ColumnName), principalTable: RewriteName(foreignKey.PrincipalTable), principalColumn: RewriteName(foreignKey.PrincipalColumn), + principalSchema: RewriteName(foreignKey.PrincipalSchema), onDelete: foreignKey.OnDeleteAction, schema: Schema); } diff --git a/Oqtane.Server/Migrations/Framework/ForeignKey.cs b/Oqtane.Server/Migrations/Framework/ForeignKey.cs index 533518ab..0a7e9e7f 100644 --- a/Oqtane.Server/Migrations/Framework/ForeignKey.cs +++ b/Oqtane.Server/Migrations/Framework/ForeignKey.cs @@ -16,6 +16,16 @@ namespace Oqtane.Migrations OnDeleteAction = onDeleteAction; } + public ForeignKey(string name, Expression> column, string principalTable, string principalColumn, string principalSchema, ReferentialAction onDeleteAction) + { + Name = name; + Column = column; + PrincipalTable = principalTable; + PrincipalColumn = principalColumn; + PrincipalSchema = principalSchema; + OnDeleteAction = onDeleteAction; + } + public string Name { get; } public Expression> Column { get;} @@ -34,6 +44,8 @@ namespace Oqtane.Migrations public string PrincipalColumn { get; } + public string PrincipalSchema { get; } = ""; + } } diff --git a/Oqtane.Server/Migrations/Tenant/05020401_RemoveLanguageName.cs b/Oqtane.Server/Migrations/Tenant/05020401_RemoveLanguageName.cs new file mode 100644 index 00000000..063b7027 --- /dev/null +++ b/Oqtane.Server/Migrations/Tenant/05020401_RemoveLanguageName.cs @@ -0,0 +1,28 @@ +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.05.02.04.01")] + public class RemoveLanguageName : MultiDatabaseMigration + { + public RemoveLanguageName(IDatabase database) : base(database) + { + } + + protected override void Up(MigrationBuilder migrationBuilder) + { + var languageEntityBuilder = new LanguageEntityBuilder(migrationBuilder, ActiveDatabase); + languageEntityBuilder.DropColumn("Name"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // not implemented + } + } +} diff --git a/Oqtane.Server/Modules/Admin/Files/Manager/FileManager.cs b/Oqtane.Server/Modules/Admin/Files/Manager/FileManager.cs index 96b75879..43dbda33 100644 --- a/Oqtane.Server/Modules/Admin/Files/Manager/FileManager.cs +++ b/Oqtane.Server/Modules/Admin/Files/Manager/FileManager.cs @@ -45,10 +45,25 @@ namespace Oqtane.Modules.Admin.Files.Manager var path = folder.Path + file.Name; var body = ""; - if (DocumentExtensions.Contains(Path.GetExtension(file.Name))) + if (System.IO.File.Exists(_fileRepository.GetFilePath(file))) { - // get the contents of the file - body = System.IO.File.ReadAllText(_fileRepository.GetFilePath(file)); + // only non-binary files can be indexed + if (DocumentExtensions.Contains(Path.GetExtension(file.Name))) + { + // get the contents of the file + try + { + body = System.IO.File.ReadAllText(_fileRepository.GetFilePath(file)); + } + catch + { + // could not read the file + } + } + } + else + { + removed = true; // file does not exist on disk } var searchContent = new SearchContent diff --git a/Oqtane.Server/Oqtane.Server.csproj b/Oqtane.Server/Oqtane.Server.csproj index 12a52f9b..993d40e2 100644 --- a/Oqtane.Server/Oqtane.Server.csproj +++ b/Oqtane.Server/Oqtane.Server.csproj @@ -1,9 +1,9 @@ - net8.0 + net9.0 Debug;Release - 5.2.2 + 6.0.0 Oqtane Shaun Walker .NET Foundation @@ -11,7 +11,7 @@ .NET Foundation https://www.oqtane.org https://github.com/oqtane/oqtane.framework/blob/dev/LICENSE - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 https://github.com/oqtane/oqtane.framework Git Oqtane @@ -33,21 +33,21 @@ - - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + - - - - + + diff --git a/Oqtane.Server/Pages/Files.cshtml.cs b/Oqtane.Server/Pages/Files.cshtml.cs index d8cee171..463534fc 100644 --- a/Oqtane.Server/Pages/Files.cshtml.cs +++ b/Oqtane.Server/Pages/Files.cshtml.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; @@ -14,6 +15,7 @@ using Oqtane.Infrastructure; using Oqtane.Models; using Oqtane.Repository; using Oqtane.Security; +using Oqtane.Services; using Oqtane.Shared; namespace Oqtane.Pages @@ -28,8 +30,10 @@ namespace Oqtane.Pages private readonly ISyncManager _syncManager; private readonly ILogManager _logger; private readonly Alias _alias; + private readonly IImageService _imageService; + private readonly ISettingRepository _settingRepository; - public FilesModel(IWebHostEnvironment environment, IFileRepository files, IUserPermissions userPermissions, IUrlMappingRepository urlMappings, ISyncManager syncManager, ILogManager logger, ITenantManager tenantManager) + public FilesModel(IWebHostEnvironment environment, IFileRepository files, IUserPermissions userPermissions, IUrlMappingRepository urlMappings, ISyncManager syncManager, ILogManager logger, ITenantManager tenantManager, IImageService imageService, ISettingRepository settingRepository) { _environment = environment; _files = files; @@ -38,111 +42,228 @@ namespace Oqtane.Pages _syncManager = syncManager; _logger = logger; _alias = tenantManager.GetAlias(); + _imageService = imageService; + _settingRepository = settingRepository; } public IActionResult OnGet(string path) { - if (!string.IsNullOrEmpty(path)) - { - path = path.Replace("\\", "/"); - var folderpath = ""; - var filename = ""; - - bool download = false; - if (Request.Query.ContainsKey("download")) - { - download = true; - } - - var segments = path.Split('/'); - if (segments.Length > 0) - { - filename = segments[segments.Length - 1].ToLower(); - if (segments.Length > 1) - { - folderpath = string.Join("/", segments, 0, segments.Length - 1).ToLower() + "/"; - } - } - - Models.File file; - if (folderpath == "id/" && int.TryParse(filename, out int fileid)) - { - file = _files.GetFile(fileid, false); - } - else - { - file = _files.GetFile(_alias.SiteId, folderpath, filename); - } - - if (file != null) - { - if (file.Folder.SiteId == _alias.SiteId && _userPermissions.IsAuthorized(User, PermissionNames.View, file.Folder.PermissionList)) - { - // calculate ETag using last modified date and file size - var etag = Convert.ToString(file.ModifiedOn.Ticks ^ file.Size, 16); - - var header = ""; - if (HttpContext.Request.Headers.ContainsKey(HeaderNames.IfNoneMatch)) - { - header = HttpContext.Request.Headers[HeaderNames.IfNoneMatch].ToString(); - } - - if (!header.Equals(etag)) - { - var filepath = _files.GetFilePath(file); - if (System.IO.File.Exists(filepath)) - { - if (download) - { - _syncManager.AddSyncEvent(_alias, EntityNames.File, file.FileId, "Download"); - return PhysicalFile(filepath, file.GetMimeType(), file.Name); - } - else - { - HttpContext.Response.Headers.Append(HeaderNames.ETag, etag); - return PhysicalFile(filepath, file.GetMimeType()); - } - } - else - { - _logger.Log(LogLevel.Error, this, LogFunction.Read, "File Does Not Exist {FilePath}", filepath); - HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; - } - } - else - { - HttpContext.Response.StatusCode = (int)HttpStatusCode.NotModified; - return Content(String.Empty); - } - } - else - { - _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Access Attempt For Site {SiteId} And Path {Path}", _alias.SiteId, path); - HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; - } - } - else - { - // look for url mapping - var urlMapping = _urlMappings.GetUrlMapping(_alias.SiteId, "files/" + folderpath + filename); - if (urlMapping != null && !string.IsNullOrEmpty(urlMapping.MappedUrl)) - { - var url = urlMapping.MappedUrl; - if (!url.StartsWith("http")) - { - var uri = new Uri(HttpContext.Request.GetEncodedUrl()); - url = uri.Scheme + "://" + uri.Authority + ((!string.IsNullOrEmpty(_alias.Path)) ? "/" + _alias.Path : "") + "/" + url; - } - return RedirectPermanent(url); - } - } - } - else + if (string.IsNullOrWhiteSpace(path)) { _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Access Attempt - Path Not Specified For Site {SiteId}", _alias.SiteId); HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return BrokenFile(); } + path = path.Replace("\\", "/"); + var folderpath = ""; + var filename = ""; + + bool download = false; + if (Request.Query.ContainsKey("download")) + { + download = true; + } + + var segments = path.Split('/'); + if (segments.Length > 0) + { + filename = segments[segments.Length - 1].ToLower(); + if (segments.Length > 1) + { + folderpath = string.Join("/", segments, 0, segments.Length - 1).ToLower() + "/"; + } + } + + Models.File file; + if (folderpath == "id/" && int.TryParse(filename, out int fileid)) + { + file = _files.GetFile(fileid, false); + } + else + { + file = _files.GetFile(_alias.SiteId, folderpath, filename); + } + + if (file == null) + { + // look for url mapping + + var urlMapping = _urlMappings.GetUrlMapping(_alias.SiteId, "files/" + folderpath + filename); + if (urlMapping != null && !string.IsNullOrEmpty(urlMapping.MappedUrl)) + { + var url = urlMapping.MappedUrl; + if (!url.StartsWith("http")) + { + var uri = new Uri(HttpContext.Request.GetEncodedUrl()); + url = uri.Scheme + "://" + uri.Authority + ((!string.IsNullOrEmpty(_alias.Path)) ? "/" + _alias.Path : "") + "/" + url; + } + + // appends the query string to the redirect url + if (Request.QueryString.HasValue && !string.IsNullOrWhiteSpace(Request.QueryString.Value)) + { + if (url.Contains('?')) + { + url += "&"; + } + else + { + url += "?"; + } + + url += Request.QueryString.Value.Substring(1); + } + + return RedirectPermanent(url); + } + + return BrokenFile(); + } + + if (file.Folder.SiteId != _alias.SiteId || !_userPermissions.IsAuthorized(User, PermissionNames.View, file.Folder.PermissionList)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized File Access Attempt For Site {SiteId} And Path {Path}", _alias.SiteId, path); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return BrokenFile(); + } + + string etag; + string downloadName = file.Name; + string filepath = _files.GetFilePath(file); + + var etagValue = file.ModifiedOn.Ticks ^ file.Size; + + bool isRequestingImageManipulation = false; + + int width = 0; + int height = 0; + if (Request.Query.TryGetValue("width", out var widthStr) && int.TryParse(widthStr, out width) && width > 0) + { + isRequestingImageManipulation = true; + etagValue ^= (width * 31); + } + if (Request.Query.TryGetValue("height", out var heightStr) && int.TryParse(heightStr, out height) && height > 0) + { + isRequestingImageManipulation = true; + etagValue ^= (height * 17); + } + + Request.Query.TryGetValue("mode", out var mode); + Request.Query.TryGetValue("position", out var position); + Request.Query.TryGetValue("background", out var background); + + if (width > 0 || height > 0) + { + if (!string.IsNullOrWhiteSpace(mode)) etagValue ^= mode.ToString().GetHashCode(); + if (!string.IsNullOrWhiteSpace(position)) etagValue ^= position.ToString().GetHashCode(); + if (!string.IsNullOrWhiteSpace(background)) etagValue ^= background.ToString().GetHashCode(); + } + + int rotate; + if (Request.Query.TryGetValue("rotate", out var rotateStr) && int.TryParse(rotateStr, out rotate) && 360 > rotate && rotate > 0) + { + isRequestingImageManipulation = true; + etagValue ^= (rotate * 13); + } + + if (Request.Query.TryGetValue("format", out var format) && _imageService.GetAvailableFormats().Contains(format.ToString())) + { + isRequestingImageManipulation = true; + etagValue ^= format.ToString().GetHashCode(); + } + + etag = Convert.ToString(etagValue, 16); + + var header = ""; + if (HttpContext.Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var ifNoneMatch)) + { + header = ifNoneMatch.ToString(); + } + + if (header.Equals(etag)) + { + HttpContext.Response.StatusCode = (int)HttpStatusCode.NotModified; + return Content(String.Empty); + } + + if (!System.IO.File.Exists(filepath)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Read, "File Does Not Exist {FilePath}", filepath); + HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; + return BrokenFile(); + } + + if (isRequestingImageManipulation) + { + var _ImageFiles = _settingRepository.GetSetting(EntityNames.Site, _alias.SiteId, "ImageFiles")?.SettingValue; + _ImageFiles = (string.IsNullOrEmpty(_ImageFiles)) ? Constants.ImageFiles : _ImageFiles; + + if (!_ImageFiles.Split(',').Contains(file.Extension.ToLower())) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "File Is Not An Image {File}", file); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return BrokenFile(); + } + + Request.Query.TryGetValue("recreate", out var recreate); + + if (!bool.TryParse(recreate, out _)) recreate = "false"; + if (!_imageService.GetAvailableFormats().Contains(format.ToString())) format = "png"; + if (width == 0 && height == 0) + { + width = file.ImageWidth; + height = file.ImageHeight; + } + + string imagepath = filepath.Replace(Path.GetExtension(filepath), "." + width.ToString() + "x" + height.ToString() + "." + format); + if (!System.IO.File.Exists(imagepath) || bool.Parse(recreate)) + { + // user has edit access to folder or folder supports the image size being created + if (_userPermissions.IsAuthorized(User, PermissionNames.Edit, file.Folder.PermissionList) || + (!string.IsNullOrEmpty(file.Folder.ImageSizes) && (file.Folder.ImageSizes == "*" || file.Folder.ImageSizes.ToLower().Split(",").Contains(width.ToString() + "x" + height.ToString())))) + { + imagepath = _imageService.CreateImage(filepath, width, height, mode, position, background, rotateStr, format, imagepath); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Invalid Image Size For Folder {Folder} {Width} {Height}", file.Folder, width, height); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + return BrokenFile(); + } + } + + if (string.IsNullOrWhiteSpace(imagepath)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Create, "Error Displaying Image For File {File} {Width} {Height}", file, widthStr, heightStr); + HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; + return BrokenFile(); + } + + downloadName = file.Name.Replace(Path.GetExtension(filepath), "." + width.ToString() + "x" + height.ToString() + "." + format); + filepath = imagepath; + } + + if (!System.IO.File.Exists(filepath)) + { + _logger.Log(LogLevel.Error, this, LogFunction.Read, "File Does Not Exist {FilePath}", filepath); + HttpContext.Response.StatusCode = (int)HttpStatusCode.NotFound; + return BrokenFile(); + } + + if (download) + { + _syncManager.AddSyncEvent(_alias, EntityNames.File, file.FileId, "Download"); + return PhysicalFile(filepath, file.GetMimeType(), downloadName); + } + else + { + HttpContext.Response.Headers.Append(HeaderNames.ETag, etag); + return PhysicalFile(filepath, file.GetMimeType()); + } + } + + private PhysicalFileResult BrokenFile() + { // broken link string errorPath = Path.Combine(Utilities.PathCombine(_environment.ContentRootPath, "wwwroot/images"), "error.png"); return PhysicalFile(errorPath, MimeUtilities.GetMimeType(errorPath)); diff --git a/Oqtane.Server/Pages/Logout.cshtml.cs b/Oqtane.Server/Pages/Logout.cshtml.cs index 40b58fb5..42334416 100644 --- a/Oqtane.Server/Pages/Logout.cshtml.cs +++ b/Oqtane.Server/Pages/Logout.cshtml.cs @@ -23,7 +23,7 @@ namespace Oqtane.Pages _syncManager = syncManager; } - public async Task OnPostAsync(string returnurl) + public async Task OnPostAsync(string returnurl, string everywhere) { if (HttpContext.User != null) { @@ -31,6 +31,10 @@ namespace Oqtane.Pages var user = _userManager.GetUser(HttpContext.User.Identity.Name, alias.SiteId); if (user != null) { + if (everywhere == "true") + { + await _userManager.LogoutUserEverywhere(user); + } _syncManager.AddSyncEvent(alias, EntityNames.User, user.UserId, SyncEventActions.Reload); } diff --git a/Oqtane.Server/Providers/DatabaseSearchProvider.cs b/Oqtane.Server/Providers/DatabaseSearchProvider.cs index c3e5d28e..a662b106 100644 --- a/Oqtane.Server/Providers/DatabaseSearchProvider.cs +++ b/Oqtane.Server/Providers/DatabaseSearchProvider.cs @@ -235,9 +235,9 @@ namespace Oqtane.Providers return text; } - public Task ResetIndex() + public Task DeleteSearchContent(int siteId) { - _searchContentRepository.DeleteAllSearchContent(); + _searchContentRepository.DeleteAllSearchContent(siteId); return Task.CompletedTask; } } diff --git a/Oqtane.Server/Providers/IdentityRevalidatingAuthenticationStateProvider.cs b/Oqtane.Server/Providers/IdentityRevalidatingAuthenticationStateProvider.cs index 3ec145f9..2599d6c2 100644 --- a/Oqtane.Server/Providers/IdentityRevalidatingAuthenticationStateProvider.cs +++ b/Oqtane.Server/Providers/IdentityRevalidatingAuthenticationStateProvider.cs @@ -40,7 +40,7 @@ namespace Oqtane.Providers } else { - return true; + return authState.User.SecurityStamp() == user.SecurityStamp; } } } diff --git a/Oqtane.Server/Repository/Interfaces/ISearchContentRepository.cs b/Oqtane.Server/Repository/Interfaces/ISearchContentRepository.cs index 8511b438..022c8012 100644 --- a/Oqtane.Server/Repository/Interfaces/ISearchContentRepository.cs +++ b/Oqtane.Server/Repository/Interfaces/ISearchContentRepository.cs @@ -12,7 +12,7 @@ namespace Oqtane.Repository void DeleteSearchContent(int searchContentId); void DeleteSearchContent(string entityName, string entryId); void DeleteSearchContent(string uniqueKey); - void DeleteAllSearchContent(); + void DeleteAllSearchContent(int siteId); SearchWord GetSearchWord(string word); SearchWord AddSearchWord(SearchWord searchWord); diff --git a/Oqtane.Server/Repository/PageRepository.cs b/Oqtane.Server/Repository/PageRepository.cs index 4bad39e3..e3bc27d0 100644 --- a/Oqtane.Server/Repository/PageRepository.cs +++ b/Oqtane.Server/Repository/PageRepository.cs @@ -91,18 +91,29 @@ namespace Oqtane.Repository public void DeletePage(int pageId) { using var db = _dbContextFactory.CreateDbContext(); - var page = db.Page.Find(pageId); - _permissions.DeletePermissions(page.SiteId, EntityNames.Page, pageId); - _settings.DeleteSettings(EntityNames.Page, pageId); - // remove page modules for page - var pageModules = db.PageModule.Where(item => item.PageId == pageId).ToList(); - foreach (var pageModule in pageModules) { - _pageModules.DeletePageModule(pageModule.PageModuleId); + var page = db.Page.Find(pageId); + _permissions.DeletePermissions(page.SiteId, EntityNames.Page, pageId); + _settings.DeleteSettings(EntityNames.Page, pageId); + // remove page modules for page + var pageModules = db.PageModule.Where(item => item.PageId == pageId).ToList(); + foreach (var pageModule in pageModules) + { + _pageModules.DeletePageModule(pageModule.PageModuleId); + } + + // At this point the page item is unaware of changes happened in other + // contexts (i.e.: the contex opened and closed in each DeletePageModule). + // Workin on page item may result in unxpected behaviour: + // better close and reopen context to work on a fresh page item. + } + + using var dbContext = _dbContextFactory.CreateDbContext(); + { + var page = dbContext.Page.Find(pageId); + dbContext.Page.Remove(page); + dbContext.SaveChanges(); } - // must occur after page modules are deleted because of cascading delete relationship - db.Page.Remove(page); - db.SaveChanges(); } } } diff --git a/Oqtane.Server/Repository/SearchContentRepository.cs b/Oqtane.Server/Repository/SearchContentRepository.cs index 53d736a5..5aa214ae 100644 --- a/Oqtane.Server/Repository/SearchContentRepository.cs +++ b/Oqtane.Server/Repository/SearchContentRepository.cs @@ -152,11 +152,17 @@ namespace Oqtane.Repository } } - public void DeleteAllSearchContent() + public void DeleteAllSearchContent(int siteId) { using var db = _dbContextFactory.CreateDbContext(); - db.SearchContent.RemoveRange(db.SearchContent); - db.SaveChanges(); + // delete in batches of 100 records + var searchContents = db.SearchContent.Where(item => item.SiteId == siteId).Take(100).ToList(); + while (searchContents.Count > 0) + { + db.SearchContent.RemoveRange(searchContents); + db.SaveChanges(); + searchContents = db.SearchContent.Where(item => item.SiteId == siteId).Take(100).ToList(); + } } public SearchWord GetSearchWord(string word) diff --git a/Oqtane.Server/Repository/SettingRepository.cs b/Oqtane.Server/Repository/SettingRepository.cs index 07a2da6d..b43a1d6a 100644 --- a/Oqtane.Server/Repository/SettingRepository.cs +++ b/Oqtane.Server/Repository/SettingRepository.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Internal; using Microsoft.Extensions.Caching.Memory; using Oqtane.Infrastructure; using Oqtane.Models; diff --git a/Oqtane.Server/Repository/SiteRepository.cs b/Oqtane.Server/Repository/SiteRepository.cs index 8d2d822c..36495c8f 100644 --- a/Oqtane.Server/Repository/SiteRepository.cs +++ b/Oqtane.Server/Repository/SiteRepository.cs @@ -441,7 +441,7 @@ namespace Oqtane.Repository pageModule.Module.PermissionList = new List(); foreach (var permission in pageTemplateModule.PermissionList) { - pageModule.Module.PermissionList.Add(permission.Clone(permission)); + pageModule.Module.PermissionList.Add(permission.Clone()); } pageModule.Module.AllPages = false; pageModule.Module.IsDeleted = false; diff --git a/Oqtane.Server/Repository/ThemeRepository.cs b/Oqtane.Server/Repository/ThemeRepository.cs index 338e73ed..2802f8f0 100644 --- a/Oqtane.Server/Repository/ThemeRepository.cs +++ b/Oqtane.Server/Repository/ThemeRepository.cs @@ -5,15 +5,11 @@ using System.IO; using System.Linq; using System.Reflection; using Microsoft.EntityFrameworkCore; -using System.Security; using Microsoft.Extensions.Caching.Memory; using Oqtane.Infrastructure; using Oqtane.Models; using Oqtane.Shared; using Oqtane.Themes; -using System.Reflection.Metadata; -using Oqtane.Migrations.Master; -using Oqtane.Modules; namespace Oqtane.Repository { diff --git a/Oqtane.Server/Repository/UserRepository.cs b/Oqtane.Server/Repository/UserRepository.cs index ffbf7412..3c0a40ad 100644 --- a/Oqtane.Server/Repository/UserRepository.cs +++ b/Oqtane.Server/Repository/UserRepository.cs @@ -75,6 +75,7 @@ namespace Oqtane.Repository userrole.RoleId = role.RoleId; userrole.EffectiveDate = null; userrole.ExpiryDate = null; + userrole.IgnoreSecurityStamp = true; _userroles.AddUserRole(userrole); } diff --git a/Oqtane.Server/Repository/UserRoleRepository.cs b/Oqtane.Server/Repository/UserRoleRepository.cs index 0a4a04eb..8af62274 100644 --- a/Oqtane.Server/Repository/UserRoleRepository.cs +++ b/Oqtane.Server/Repository/UserRoleRepository.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Oqtane.Infrastructure; @@ -14,13 +15,15 @@ namespace Oqtane.Repository private readonly IDbContextFactory _dbContextFactory; private readonly IRoleRepository _roles; private readonly ITenantManager _tenantManager; + private readonly UserManager _identityUserManager; private readonly IMemoryCache _cache; - public UserRoleRepository(IDbContextFactory dbContextFactory, IRoleRepository roles, ITenantManager tenantManager, IMemoryCache cache) + public UserRoleRepository(IDbContextFactory dbContextFactory, IRoleRepository roles, ITenantManager tenantManager, UserManager identityUserManager, IMemoryCache cache) { _dbContextFactory = dbContextFactory; _roles = roles; _tenantManager = tenantManager; + _identityUserManager = identityUserManager; _cache = cache; } @@ -69,9 +72,12 @@ namespace Oqtane.Repository DeleteUserRoles(userRole.UserId); } - var alias = _tenantManager.GetAlias(); - _cache.Remove($"user:{userRole.UserId}:{alias.SiteKey}"); - _cache.Remove($"userroles:{userRole.UserId}:{alias.SiteKey}"); + if (!userRole.IgnoreSecurityStamp) + { + UpdateSecurityStamp(userRole.UserId); + } + + RefreshCache(userRole.UserId); return userRole; } @@ -82,9 +88,12 @@ namespace Oqtane.Repository db.Entry(userRole).State = EntityState.Modified; db.SaveChanges(); - var alias = _tenantManager.GetAlias(); - _cache.Remove($"user:{userRole.UserId}:{alias.SiteKey}"); - _cache.Remove($"userroles:{userRole.UserId}:{alias.SiteKey}"); + if (!userRole.IgnoreSecurityStamp) + { + UpdateSecurityStamp(userRole.UserId); + } + + RefreshCache(userRole.UserId); return userRole; } @@ -144,9 +153,8 @@ namespace Oqtane.Repository db.UserRole.Remove(userRole); db.SaveChanges(); - var alias = _tenantManager.GetAlias(); - _cache.Remove($"user:{userRole.UserId}:{alias.SiteKey}"); - _cache.Remove($"userroles:{userRole.UserId}:{alias.SiteKey}"); + UpdateSecurityStamp(userRole.UserId); + RefreshCache(userRole.UserId); } public void DeleteUserRoles(int userId) @@ -158,9 +166,32 @@ namespace Oqtane.Repository } db.SaveChanges(); + UpdateSecurityStamp(userId); + RefreshCache(userId); + } + + private void UpdateSecurityStamp(int userId) + { + using var db = _dbContextFactory.CreateDbContext(); + var user = db.User.Find(userId); + if (user != null) + { + var identityuser = _identityUserManager.FindByNameAsync(user.Username).GetAwaiter().GetResult(); + if (identityuser != null) + { + _identityUserManager.UpdateSecurityStampAsync(identityuser).GetAwaiter().GetResult(); + } + } + } + + private void RefreshCache(int userId) + { var alias = _tenantManager.GetAlias(); - _cache.Remove($"user:{userId}:{alias.SiteKey}"); - _cache.Remove($"userroles:{userId}:{alias.SiteKey}"); + if (alias != null) + { + _cache.Remove($"user:{userId}:{alias.SiteKey}"); + _cache.Remove($"userroles:{userId}:{alias.SiteKey}"); + } } } } diff --git a/Oqtane.Server/Security/ClaimsPrincipalFactory.cs b/Oqtane.Server/Security/ClaimsPrincipalFactory.cs index 00e072fa..0bb0b43e 100644 --- a/Oqtane.Server/Security/ClaimsPrincipalFactory.cs +++ b/Oqtane.Server/Security/ClaimsPrincipalFactory.cs @@ -13,14 +13,17 @@ namespace Oqtane.Security public class ClaimsPrincipalFactory : UserClaimsPrincipalFactory where TUser : IdentityUser { private readonly ITenantManager _tenants; + // cannot utilize IUserManager due to circular references - which is fine as this method is only called on login private readonly IUserRepository _users; private readonly IUserRoleRepository _userRoles; + private readonly UserManager _userManager; public ClaimsPrincipalFactory(UserManager userManager, IOptions optionsAccessor, ITenantManager tenants, IUserRepository users, IUserRoleRepository userroles) : base(userManager, optionsAccessor) { _tenants = tenants; _users = users; _userRoles = userroles; + _userManager = userManager; } protected override async Task GenerateClaimsAsync(TUser identityuser) @@ -33,6 +36,7 @@ namespace Oqtane.Security Alias alias = _tenants.GetAlias(); if (alias != null) { + user.SecurityStamp = await _userManager.GetSecurityStampAsync(identityuser); List userroles = _userRoles.GetUserRoles(user.UserId, alias.SiteId).ToList(); identity = UserSecurity.CreateClaimsIdentity(alias, user, userroles); } diff --git a/Oqtane.Server/Security/PrincipalValidator.cs b/Oqtane.Server/Security/PrincipalValidator.cs index 269fbc5b..45e99c34 100644 --- a/Oqtane.Server/Security/PrincipalValidator.cs +++ b/Oqtane.Server/Security/PrincipalValidator.cs @@ -3,18 +3,17 @@ using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication.Cookies; using Oqtane.Infrastructure; -using Oqtane.Repository; using Oqtane.Models; -using System.Collections.Generic; using Oqtane.Extensions; using Oqtane.Shared; -using System.IO; +using Oqtane.Managers; +using Microsoft.AspNetCore.Authentication; namespace Oqtane.Security { public static class PrincipalValidator { - public static Task ValidateAsync(CookieValidatePrincipalContext context) + public static async Task ValidateAsync(CookieValidatePrincipalContext context) { if (context != null && context.Principal.Identity.IsAuthenticated && context.Principal.Identity.Name != null) { @@ -24,60 +23,50 @@ namespace Oqtane.Security // check if framework is installed if (config.IsInstalled() && !path.StartsWith("/_")) // ignore Blazor framework requests { - // get current site + var _logger = context.HttpContext.RequestServices.GetService(typeof(ILogManager)) as ILogManager; + var alias = context.HttpContext.GetAlias(); if (alias != null) { - var claims = context.Principal.Claims; + var userManager = context.HttpContext.RequestServices.GetService(typeof(IUserManager)) as IUserManager; + var user = userManager.GetUser(context.Principal.UserId(), alias.SiteId); // cached - // check if principal has roles and matches current site - if (!claims.Any(item => item.Type == ClaimTypes.Role) || !claims.Any(item => item.Type == "sitekey" && item.Value == alias.SiteKey)) + // check if user is valid, not deleted, has roles, and security stamp has not changed + if (user != null && !user.IsDeleted && !string.IsNullOrEmpty(user.Roles) && context.Principal.SecurityStamp() == user.SecurityStamp) { - var userRepository = context.HttpContext.RequestServices.GetService(typeof(IUserRepository)) as IUserRepository; - var userRoleRepository = context.HttpContext.RequestServices.GetService(typeof(IUserRoleRepository)) as IUserRoleRepository; - var _logger = context.HttpContext.RequestServices.GetService(typeof(ILogManager)) as ILogManager; - - User user = userRepository.GetUser(context.Principal.Identity.Name); - if (user != null) + // validate sitekey in case user has changed sites in installation + if (context.Principal.SiteKey() != alias.SiteKey || !context.Principal.Roles().Any()) { - // replace principal with roles for current site - List userroles = userRoleRepository.GetUserRoles(user.UserId, alias.SiteId).ToList(); - if (userroles.Any()) - { - var identity = UserSecurity.CreateClaimsIdentity(alias, user, userroles); - context.ReplacePrincipal(new ClaimsPrincipal(identity)); - context.ShouldRenew = true; - Log(_logger, alias, "Permissions Updated For User {Username} Accessing {Url}", context.Principal.Identity.Name, path); - } - else - { - // user has no roles - remove principal - Log(_logger, alias, "Permissions Removed For User {Username} Accessing {Url}", context.Principal.Identity.Name, path); - context.RejectPrincipal(); - } - } - else - { - // user does not exist - remove principal - Log(_logger, alias, "Permissions Removed For User {Username} Accessing {Url}", context.Principal.Identity.Name, path); - context.RejectPrincipal(); + // refresh principal + var identity = UserSecurity.CreateClaimsIdentity(alias, user); + context.ReplacePrincipal(new ClaimsPrincipal(identity)); + context.ShouldRenew = true; + Log(_logger, alias, "Permissions Refreshed For User {Username} Accessing {Url}", context.Principal.Identity.Name, path); } } + else + { + // remove principal (ie. log user out) + Log(_logger, alias, "Permissions Removed For User {Username} Accessing {Url}", context.Principal.Identity.Name, path); + context.RejectPrincipal(); + await context.HttpContext.SignOutAsync(Constants.AuthenticationScheme); + } } else { - // user is signed in but tenant cannot be determined + // user is signed in but site cannot be determined + Log(_logger, alias, "Alias Could Not Be Resolved For User {Username} Accessing {Url}", context.Principal.Identity.Name, path); } } } - return Task.CompletedTask; } private static void Log (ILogManager logger, Alias alias, string message, string username, string path) { if (!path.StartsWith("/api/")) // reduce log verbosity { - logger.Log(alias.SiteId, LogLevel.Information, "LoginValidation", Enums.LogFunction.Security, message, username, path); + var siteId = (alias != null) ? alias.SiteId : -1; + logger.Log(siteId, LogLevel.Information, "UserValidation", Enums.LogFunction.Security, message, username, path); } } } diff --git a/Oqtane.Server/Services/ImageService.cs b/Oqtane.Server/Services/ImageService.cs new file mode 100644 index 00000000..e22f3b8e --- /dev/null +++ b/Oqtane.Server/Services/ImageService.cs @@ -0,0 +1,124 @@ +using Oqtane.Enums; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Processing; +using System.IO; +using System; +using SixLabors.ImageSharp; +using Oqtane.Infrastructure; +using Oqtane.Shared; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Webp; +using System.Linq; + +namespace Oqtane.Services +{ + public class ImageService : IImageService + { + private readonly ILogManager _logger; + private static readonly string[] _formats = ["png", "webp"]; + + public ImageService(ILogManager logger) + { + _logger = logger; + } + + public string[] GetAvailableFormats() + { + return _formats; + } + + public string CreateImage(string filepath, int width, int height, string mode, string position, string background, string rotate, string format, string imagepath) + { + try + { + // params validation + if (!Enum.TryParse(mode, true, out ResizeMode _)) mode = "crop"; + if (!Enum.TryParse(position, true, out AnchorPositionMode _)) position = "center"; + if (!Color.TryParseHex("#" + background, out _)) background = "transparent"; + if (!int.TryParse(rotate, out _)) rotate = "0"; + rotate = (int.Parse(rotate) < 0 || int.Parse(rotate) > 360) ? "0" : rotate; + if (!_formats.Contains(format)) format = "png"; + + using (var stream = new FileStream(filepath, FileMode.Open, FileAccess.Read)) + { + stream.Position = 0; + using (var image = Image.Load(stream)) + { + int.TryParse(rotate, out int angle); + Enum.TryParse(mode, true, out ResizeMode resizemode); + Enum.TryParse(position, true, out AnchorPositionMode anchorpositionmode); + + if (width == 0 && height == 0) + { + width = image.Width; + height = image.Height; + } + + IImageEncoder encoder; + var resizeOptions = new ResizeOptions + { + Mode = resizemode, + Position = anchorpositionmode, + Size = new Size(width, height) + }; + + if (background != "transparent") + { + resizeOptions.PadColor = Color.ParseHex("#" + background); + encoder = GetEncoder(format, transparent: false); + } + else + { + encoder = GetEncoder(format, transparent: true); + } + + image.Mutate(x => x + .AutoOrient() // auto orient the image + .Rotate(angle) + .Resize(resizeOptions)); + + image.Save(imagepath, encoder); + } + } + } + catch (Exception ex) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, ex, "Error Creating Image For File {FilePath} {Width} {Height} {Mode} {Rotate} {Error}", filepath, width, height, mode, rotate, ex.Message); + imagepath = ""; + } + + return imagepath; + } + + private static IImageEncoder GetEncoder(string format, bool transparent) + { + return format switch + { + "png" => GetPngEncoder(transparent), + "webp" => GetWebpEncoder(transparent), + _ => GetPngEncoder(transparent), + }; + } + + private static PngEncoder GetPngEncoder(bool transparent) + { + return new PngEncoder() + { + ColorType = transparent ? PngColorType.RgbWithAlpha : PngColorType.Rgb, + TransparentColorMode = transparent ? PngTransparentColorMode.Preserve : PngTransparentColorMode.Clear, + BitDepth = PngBitDepth.Bit8, + CompressionLevel = PngCompressionLevel.BestSpeed + }; + } + + private static WebpEncoder GetWebpEncoder(bool transparent) + { + return new WebpEncoder() + { + FileFormat = WebpFileFormatType.Lossy, + Quality = 60, + TransparentColorMode = transparent ? WebpTransparentColorMode.Preserve : WebpTransparentColorMode.Clear, + }; + } + } +} diff --git a/Oqtane.Server/Services/LocalizationCookieService.cs b/Oqtane.Server/Services/LocalizationCookieService.cs new file mode 100644 index 00000000..1bedfc6f --- /dev/null +++ b/Oqtane.Server/Services/LocalizationCookieService.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; +using Oqtane.Documentation; + +namespace Oqtane.Services +{ + [PrivateApi("Don't show in the documentation, as everything should use the Interface")] + public class ServerLocalizationCookieService : ILocalizationCookieService + { + private readonly IHttpContextAccessor _accessor; + + public ServerLocalizationCookieService(IHttpContextAccessor accessor) + { + _accessor = accessor; + } + + public Task SetLocalizationCookieAsync(string culture) + { + var localizationCookieValue = CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)); + + _accessor.HttpContext.Response.Cookies.Append(CookieRequestCultureProvider.DefaultCookieName, localizationCookieValue, new CookieOptions + { + Path = "/", + Expires = DateTimeOffset.UtcNow.AddYears(365), + SameSite = SameSiteMode.Lax, + Secure = true, // Ensure the cookie is only sent over HTTPS + HttpOnly = false // cookie is updated using JS Interop in Interactive render mode + }); + + return Task.CompletedTask; + } + } +} diff --git a/Oqtane.Server/Services/SearchService.cs b/Oqtane.Server/Services/SearchService.cs index 528f48e1..9a4f7ea1 100644 --- a/Oqtane.Server/Services/SearchService.cs +++ b/Oqtane.Server/Services/SearchService.cs @@ -149,6 +149,12 @@ namespace Oqtane.Services return result; } + public async Task DeleteSearchContentsAsync(int siteId) + { + var searchProvider = GetSearchProvider(siteId); + await searchProvider.DeleteSearchContent(siteId); + } + private ISearchProvider GetSearchProvider(int siteId) { var providerName = GetSearchProviderSetting(siteId); diff --git a/Oqtane.Server/Services/SiteService.cs b/Oqtane.Server/Services/SiteService.cs index 595c4ddd..84d752c2 100644 --- a/Oqtane.Server/Services/SiteService.cs +++ b/Oqtane.Server/Services/SiteService.cs @@ -32,6 +32,7 @@ namespace Oqtane.Services private readonly ILogManager _logger; private readonly IMemoryCache _cache; private readonly IHttpContextAccessor _accessor; + private readonly string _private = "[PRIVATE]"; public ServerSiteService(ISiteRepository sites, IPageRepository pages, IThemeRepository themes, IPageModuleRepository pageModules, IModuleDefinitionRepository moduleDefinitions, ILanguageRepository languages, IUserPermissions userPermissions, ISettingRepository settings, ITenantManager tenantManager, ISyncManager syncManager, ILogManager logger, IMemoryCache cache, IHttpContextAccessor accessor) { @@ -69,20 +70,35 @@ namespace Oqtane.Services return GetSite(siteId); }); + // clone object so that cache is not mutated + site = site.Clone(); + + // trim site settings based on user permissions + site.Settings = site.Settings + .Where(item => !item.Value.StartsWith(_private) || _accessor.HttpContext.User.IsInRole(RoleNames.Admin)) + .ToDictionary(setting => setting.Key, setting => setting.Value.Replace(_private, "")); + // trim pages based on user permissions var pages = new List(); foreach (Page page in site.Pages) { if (!page.IsDeleted && _userPermissions.IsAuthorized(_accessor.HttpContext.User, PermissionNames.View, page.PermissionList) && (Utilities.IsEffectiveAndNotExpired(page.EffectiveDate, page.ExpiryDate) || _userPermissions.IsAuthorized(_accessor.HttpContext.User, PermissionNames.Edit, page.PermissionList))) { + page.Settings = page.Settings + .Where(item => !item.Value.StartsWith(_private) || _userPermissions.IsAuthorized(_accessor.HttpContext.User, PermissionNames.Edit, page.PermissionList)) + .ToDictionary(setting => setting.Key, setting => setting.Value.Replace(_private, "")); pages.Add(page); } } - - // clone object so that cache is not mutated - site = site.Clone(site); site.Pages = pages; + // get language display name for user + foreach (Language language in site.Languages) + { + language.Name = CultureInfo.GetCultureInfo(language.Code).DisplayName; + } + site.Languages = site.Languages.OrderBy(item => item.Name).ToList(); + return Task.FromResult(site); } @@ -94,14 +110,13 @@ namespace Oqtane.Services { // site settings site.Settings = _settings.GetSettings(EntityNames.Site, site.SiteId) - .Where(item => !item.IsPrivate || _accessor.HttpContext.User.IsInRole(RoleNames.Admin)) - .ToDictionary(setting => setting.SettingName, setting => setting.SettingValue); + .ToDictionary(setting => setting.SettingName, setting => (setting.IsPrivate ? _private : "") + setting.SettingValue); - // populate File Extensions + // populate file extensions site.ImageFiles = site.Settings.ContainsKey("ImageFiles") && !string.IsNullOrEmpty(site.Settings["ImageFiles"]) ? site.Settings["ImageFiles"] : Constants.ImageFiles; site.UploadableFiles = site.Settings.ContainsKey("UploadableFiles") && !string.IsNullOrEmpty(site.Settings["UploadableFiles"]) - ? site.Settings["UploadableFiles"] : Constants.UploadableFiles; + ? site.Settings["UploadableFiles"] : Constants.UploadableFiles; // pages List settings = _settings.GetSettings(EntityNames.Page).ToList(); @@ -109,21 +124,23 @@ namespace Oqtane.Services foreach (Page page in _pages.GetPages(site.SiteId)) { page.Settings = settings.Where(item => item.EntityId == page.PageId) - .Where(item => !item.IsPrivate || _userPermissions.IsAuthorized(_accessor.HttpContext.User, PermissionNames.Edit, page.PermissionList)) - .ToDictionary(setting => setting.SettingName, setting => setting.SettingValue); + .ToDictionary(setting => setting.SettingName, setting => (setting.IsPrivate ? _private : "") + setting.SettingValue); site.Pages.Add(page); } site.Pages = GetPagesHierarchy(site.Pages); // framework modules - var modules = GetModules(site.SiteId); + var modules = GetPageModules(site.SiteId); site.Settings.Add(Constants.AdminDashboardModule, modules.FirstOrDefault(item => item.ModuleDefinitionName == Constants.AdminDashboardModule).ModuleId.ToString()); site.Settings.Add(Constants.PageManagementModule, modules.FirstOrDefault(item => item.ModuleDefinitionName == Constants.PageManagementModule).ModuleId.ToString()); // languages site.Languages = _languages.GetLanguages(site.SiteId).ToList(); var defaultCulture = CultureInfo.GetCultureInfo(Constants.DefaultCulture); - site.Languages.Add(new Language { Code = defaultCulture.Name, Name = defaultCulture.DisplayName, Version = Constants.Version, IsDefault = !site.Languages.Any(l => l.IsDefault) }); + if (!site.Languages.Exists(item => item.Code == defaultCulture.Name)) + { + site.Languages.Add(new Language { Code = defaultCulture.Name, Name = "", Version = Constants.Version, IsDefault = !site.Languages.Any(l => l.IsDefault) }); + } // themes site.Themes = _themes.FilterThemes(_themes.GetThemes().ToList()); @@ -249,31 +266,28 @@ namespace Oqtane.Services public Task> GetModulesAsync(int siteId, int pageId) { var alias = _tenantManager.GetAlias(); - var sitemodules = _cache.GetOrCreate($"modules:{alias.SiteKey}", entry => - { - entry.SlidingExpiration = TimeSpan.FromMinutes(30); - return GetModules(siteId); - }); - - var modules = new List(); - foreach (Module module in sitemodules.Where(item => (item.PageId == pageId || pageId == -1) && !item.IsDeleted && _userPermissions.IsAuthorized(_accessor.HttpContext.User, PermissionNames.View, item.PermissionList))) - { - if (Utilities.IsEffectiveAndNotExpired(module.EffectiveDate, module.ExpiryDate) || _userPermissions.IsAuthorized(_accessor.HttpContext.User, PermissionNames.Edit, module.PermissionList)) - { - modules.Add(module); - } - } - return Task.FromResult(modules); - } - - private List GetModules(int siteId) - { - var alias = _tenantManager.GetAlias(); - return _cache.GetOrCreate($"modules:{alias.SiteKey}", entry => + var modules = _cache.GetOrCreate($"modules:{alias.SiteKey}", entry => { entry.SlidingExpiration = TimeSpan.FromMinutes(30); return GetPageModules(siteId); }); + + // clone object so that cache is not mutated + modules = modules.ConvertAll(module => module.Clone()); + + // trim modules for current page based on user permissions + var pagemodules = new List(); + foreach (Module module in modules.Where(item => (item.PageId == pageId || pageId == -1) && !item.IsDeleted && _userPermissions.IsAuthorized(_accessor.HttpContext.User, PermissionNames.View, item.PermissionList))) + { + if (Utilities.IsEffectiveAndNotExpired(module.EffectiveDate, module.ExpiryDate) || _userPermissions.IsAuthorized(_accessor.HttpContext.User, PermissionNames.Edit, module.PermissionList)) + { + module.Settings = module.Settings + .Where(item => !item.Value.StartsWith(_private) || _userPermissions.IsAuthorized(_accessor.HttpContext.User, PermissionNames.Edit, module.PermissionList)) + .ToDictionary(setting => setting.Key, setting => setting.Value.Replace(_private, "")); + pagemodules.Add(module); + } + } + return Task.FromResult(pagemodules); } private List GetPageModules(int siteId) @@ -311,8 +325,7 @@ namespace Oqtane.Services ModuleDefinition = _moduleDefinitions.FilterModuleDefinition(moduledefinitions.Find(item => item.ModuleDefinitionName == pagemodule.Module.ModuleDefinitionName)), Settings = settings.Where(item => item.EntityId == pagemodule.ModuleId) - .Where(item => !item.IsPrivate || _userPermissions.IsAuthorized(_accessor.HttpContext.User, PermissionNames.Edit, pagemodule.Module.PermissionList)) - .ToDictionary(setting => setting.SettingName, setting => setting.SettingValue) + .ToDictionary(setting => setting.SettingName, setting => (setting.IsPrivate ? _private : "") + setting.SettingValue) }; modules.Add(module); diff --git a/Oqtane.Server/Startup.cs b/Oqtane.Server/Startup.cs index 164d8661..d873bd24 100644 --- a/Oqtane.Server/Startup.cs +++ b/Oqtane.Server/Startup.cs @@ -159,7 +159,7 @@ namespace Oqtane } }).AddHubOptions(options => { - options.MaximumReceiveMessageSize = null; // no limit (for large amnounts of data ie. textarea components) + options.MaximumReceiveMessageSize = null; // no limit (for large amounts of data ie. textarea components) }) .AddInteractiveWebAssemblyComponents(); diff --git a/Oqtane.Server/wwwroot/Modules/Templates/External/Client/Services/[Module]Service.cs b/Oqtane.Server/wwwroot/Modules/Templates/External/Client/Services/[Module]Service.cs index 0161a66b..23f10772 100644 --- a/Oqtane.Server/wwwroot/Modules/Templates/External/Client/Services/[Module]Service.cs +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Client/Services/[Module]Service.cs @@ -9,7 +9,7 @@ namespace [Owner].Module.[Module].Services { public class [Module]Service : ServiceBase, I[Module]Service { - public [Module]Service(IHttpClientFactory http, SiteState siteState) : base(http, siteState) { } + public [Module]Service(HttpClient http, SiteState siteState) : base(http, siteState) { } private string Apiurl => CreateApiUrl("[Module]"); diff --git a/Oqtane.Server/wwwroot/Modules/Templates/External/Client/[Owner].Module.[Module].Client.csproj b/Oqtane.Server/wwwroot/Modules/Templates/External/Client/[Owner].Module.[Module].Client.csproj index 9d3a0460..0bc7b7c7 100644 --- a/Oqtane.Server/wwwroot/Modules/Templates/External/Client/[Owner].Module.[Module].Client.csproj +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Client/[Owner].Module.[Module].Client.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 1.0.0 [Owner] [Owner] @@ -13,12 +13,11 @@ - - - - - - + + + + + diff --git a/Oqtane.Server/wwwroot/Modules/Templates/External/Package/[Owner].Module.[Module].Package.csproj b/Oqtane.Server/wwwroot/Modules/Templates/External/Package/[Owner].Module.[Module].Package.csproj index e7843bff..b255b8bb 100644 --- a/Oqtane.Server/wwwroot/Modules/Templates/External/Package/[Owner].Module.[Module].Package.csproj +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Package/[Owner].Module.[Module].Package.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 false false diff --git a/Oqtane.Server/wwwroot/Modules/Templates/External/Package/[Owner].Module.[Module].nuspec b/Oqtane.Server/wwwroot/Modules/Templates/External/Package/[Owner].Module.[Module].nuspec index a09ebb95..4098fedf 100644 --- a/Oqtane.Server/wwwroot/Modules/Templates/External/Package/[Owner].Module.[Module].nuspec +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Package/[Owner].Module.[Module].nuspec @@ -20,12 +20,12 @@ - - - - - - + + + + + + diff --git a/Oqtane.Server/wwwroot/Modules/Templates/External/Package/debug.cmd b/Oqtane.Server/wwwroot/Modules/Templates/External/Package/debug.cmd index e59e74cd..87d0dcf3 100644 --- a/Oqtane.Server/wwwroot/Modules/Templates/External/Package/debug.cmd +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Package/debug.cmd @@ -1,7 +1,7 @@ -XCOPY "..\Client\bin\Debug\net8.0\[Owner].Module.[Module].Client.Oqtane.dll" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net8.0\" /Y -XCOPY "..\Client\bin\Debug\net8.0\[Owner].Module.[Module].Client.Oqtane.pdb" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net8.0\" /Y -XCOPY "..\Server\bin\Debug\net8.0\[Owner].Module.[Module].Server.Oqtane.dll" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net8.0\" /Y -XCOPY "..\Server\bin\Debug\net8.0\[Owner].Module.[Module].Server.Oqtane.pdb" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net8.0\" /Y -XCOPY "..\Shared\bin\Debug\net8.0\[Owner].Module.[Module].Shared.Oqtane.dll" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net8.0\" /Y -XCOPY "..\Shared\bin\Debug\net8.0\[Owner].Module.[Module].Shared.Oqtane.pdb" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net8.0\" /Y +XCOPY "..\Client\bin\Debug\net9.0\[Owner].Module.[Module].Client.Oqtane.dll" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net9.0\" /Y +XCOPY "..\Client\bin\Debug\net9.0\[Owner].Module.[Module].Client.Oqtane.pdb" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net9.0\" /Y +XCOPY "..\Server\bin\Debug\net9.0\[Owner].Module.[Module].Server.Oqtane.dll" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net9.0\" /Y +XCOPY "..\Server\bin\Debug\net9.0\[Owner].Module.[Module].Server.Oqtane.pdb" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net9.0\" /Y +XCOPY "..\Shared\bin\Debug\net9.0\[Owner].Module.[Module].Shared.Oqtane.dll" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net9.0\" /Y +XCOPY "..\Shared\bin\Debug\net9.0\[Owner].Module.[Module].Shared.Oqtane.pdb" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net9.0\" /Y XCOPY "..\Server\wwwroot\*" "..\..\[RootFolder]\Oqtane.Server\wwwroot\" /Y /S /I diff --git a/Oqtane.Server/wwwroot/Modules/Templates/External/Package/debug.sh b/Oqtane.Server/wwwroot/Modules/Templates/External/Package/debug.sh index 792ce75c..7a5cc808 100644 --- a/Oqtane.Server/wwwroot/Modules/Templates/External/Package/debug.sh +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Package/debug.sh @@ -1,7 +1,7 @@ -cp -f "../Client/bin/Debug/net8.0/[Owner].Module.[Module].Client.Oqtane.dll" "../../oqtane.framework/Oqtane.Server/bin/Debug/net8.0/" -cp -f "../Client/bin/Debug/net8.0/[Owner].Module.[Module].Client.Oqtane.pdb" "../../oqtane.framework/Oqtane.Server/bin/Debug/net8.0/" -cp -f "../Server/bin/Debug/net8.0/[Owner].Module.[Module].Server.Oqtane.dll" "../../oqtane.framework/Oqtane.Server/bin/Debug/net8.0/" -cp -f "../Server/bin/Debug/net8.0/[Owner].Module.[Module].Server.Oqtane.pdb" "../../oqtane.framework/Oqtane.Server/bin/Debug/net8.0/" -cp -f "../Shared/bin/Debug/net8.0/[Owner].Module.[Module].Shared.Oqtane.dll" "../../oqtane.framework/Oqtane.Server/bin/Debug/net8.0/" -cp -f "../Shared/bin/Debug/net8.0/[Owner].Module.[Module].Shared.Oqtane.pdb" "../../oqtane.framework/Oqtane.Server/bin/Debug/net8.0/" +cp -f "../Client/bin/Debug/net9.0/[Owner].Module.[Module].Client.Oqtane.dll" "../../oqtane.framework/Oqtane.Server/bin/Debug/net9.0/" +cp -f "../Client/bin/Debug/net9.0/[Owner].Module.[Module].Client.Oqtane.pdb" "../../oqtane.framework/Oqtane.Server/bin/Debug/net9.0/" +cp -f "../Server/bin/Debug/net9.0/[Owner].Module.[Module].Server.Oqtane.dll" "../../oqtane.framework/Oqtane.Server/bin/Debug/net9.0/" +cp -f "../Server/bin/Debug/net9.0/[Owner].Module.[Module].Server.Oqtane.pdb" "../../oqtane.framework/Oqtane.Server/bin/Debug/net9.0/" +cp -f "../Shared/bin/Debug/net9.0/[Owner].Module.[Module].Shared.Oqtane.dll" "../../oqtane.framework/Oqtane.Server/bin/Debug/net9.0/" +cp -f "../Shared/bin/Debug/net9.0/[Owner].Module.[Module].Shared.Oqtane.pdb" "../../oqtane.framework/Oqtane.Server/bin/Debug/net9.0/" cp -rf "../Server/wwwroot/"* "../../oqtane.framework/Oqtane.Server/wwwroot/" diff --git a/Oqtane.Server/wwwroot/Modules/Templates/External/Server/[Owner].Module.[Module].Server.csproj b/Oqtane.Server/wwwroot/Modules/Templates/External/Server/[Owner].Module.[Module].Server.csproj index e8540965..550e7e32 100644 --- a/Oqtane.Server/wwwroot/Modules/Templates/External/Server/[Owner].Module.[Module].Server.csproj +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Server/[Owner].Module.[Module].Server.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 true 1.0.0 [Owner].Module.[Module] @@ -19,10 +19,10 @@ - - - - + + + + diff --git a/Oqtane.Server/wwwroot/Modules/Templates/External/Shared/[Owner].Module.[Module].Shared.csproj b/Oqtane.Server/wwwroot/Modules/Templates/External/Shared/[Owner].Module.[Module].Shared.csproj index a4cc724b..dd2e3d40 100644 --- a/Oqtane.Server/wwwroot/Modules/Templates/External/Shared/[Owner].Module.[Module].Shared.csproj +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Shared/[Owner].Module.[Module].Shared.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 1.0.0 [Owner].Module.[Module] [Owner] diff --git a/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.BlazorTheme/Theme.css b/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.BlazorTheme/Theme.css index 2e8a7265..99020c83 100644 --- a/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.BlazorTheme/Theme.css +++ b/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.BlazorTheme/Theme.css @@ -117,6 +117,10 @@ margin: .5rem; } +.app-logo .navbar-brand { + color: white; +} + @media (max-width: 767.98px) { .main .top-row { display: none; diff --git a/Oqtane.Server/wwwroot/Themes/Templates/External/Client/[Owner].Theme.[Theme].Client.csproj b/Oqtane.Server/wwwroot/Themes/Templates/External/Client/[Owner].Theme.[Theme].Client.csproj index 2dca8a8e..a40bfb11 100644 --- a/Oqtane.Server/wwwroot/Themes/Templates/External/Client/[Owner].Theme.[Theme].Client.csproj +++ b/Oqtane.Server/wwwroot/Themes/Templates/External/Client/[Owner].Theme.[Theme].Client.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 1.0.0 [Owner] [Owner] @@ -13,9 +13,9 @@ - - - + + + diff --git a/Oqtane.Server/wwwroot/Themes/Templates/External/Package/[Owner].Theme.[Theme].Package.csproj b/Oqtane.Server/wwwroot/Themes/Templates/External/Package/[Owner].Theme.[Theme].Package.csproj index 0f6cc8ea..e77b1274 100644 --- a/Oqtane.Server/wwwroot/Themes/Templates/External/Package/[Owner].Theme.[Theme].Package.csproj +++ b/Oqtane.Server/wwwroot/Themes/Templates/External/Package/[Owner].Theme.[Theme].Package.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 false false diff --git a/Oqtane.Server/wwwroot/Themes/Templates/External/Package/[Owner].Theme.[Theme].nuspec b/Oqtane.Server/wwwroot/Themes/Templates/External/Package/[Owner].Theme.[Theme].nuspec index 584fc6a6..917b02c9 100644 --- a/Oqtane.Server/wwwroot/Themes/Templates/External/Package/[Owner].Theme.[Theme].nuspec +++ b/Oqtane.Server/wwwroot/Themes/Templates/External/Package/[Owner].Theme.[Theme].nuspec @@ -20,8 +20,8 @@ - - + + diff --git a/Oqtane.Server/wwwroot/Themes/Templates/External/Package/debug.cmd b/Oqtane.Server/wwwroot/Themes/Templates/External/Package/debug.cmd index 2ece53b9..31c7cf33 100644 --- a/Oqtane.Server/wwwroot/Themes/Templates/External/Package/debug.cmd +++ b/Oqtane.Server/wwwroot/Themes/Templates/External/Package/debug.cmd @@ -1,3 +1,3 @@ -XCOPY "..\Client\bin\Debug\net8.0\[Owner].Theme.[Theme].Client.Oqtane.dll" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net8.0\" /Y -XCOPY "..\Client\bin\Debug\net8.0\[Owner].Theme.[Theme].Client.Oqtane.pdb" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net8.0\" /Y +XCOPY "..\Client\bin\Debug\net9.0\[Owner].Theme.[Theme].Client.Oqtane.dll" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net9.0\" /Y +XCOPY "..\Client\bin\Debug\net9.0\[Owner].Theme.[Theme].Client.Oqtane.pdb" "..\..\[RootFolder]\Oqtane.Server\bin\Debug\net9.0\" /Y XCOPY "..\Client\wwwroot\*" "..\..\[RootFolder]\Oqtane.Server\wwwroot\" /Y /S /I diff --git a/Oqtane.Server/wwwroot/Themes/Templates/External/Package/debug.sh b/Oqtane.Server/wwwroot/Themes/Templates/External/Package/debug.sh index 52ec3384..4ac1ad54 100644 --- a/Oqtane.Server/wwwroot/Themes/Templates/External/Package/debug.sh +++ b/Oqtane.Server/wwwroot/Themes/Templates/External/Package/debug.sh @@ -1,3 +1,3 @@ -cp -f "../Client/bin/Debug/net8.0/[Owner].Theme.[Theme].Client.Oqtane.dll" "../../oqtane.framework/Oqtane.Server/bin/Debug/net8.0/" -cp -f "../Client/bin/Debug/net8.0/[Owner].Theme.[Theme].Client.Oqtane.pdb" "../../oqtane.framework/Oqtane.Server/bin/Debug/net8.0/" +cp -f "../Client/bin/Debug/net9.0/[Owner].Theme.[Theme].Client.Oqtane.dll" "../../oqtane.framework/Oqtane.Server/bin/Debug/net9.0/" +cp -f "../Client/bin/Debug/net9.0/[Owner].Theme.[Theme].Client.Oqtane.pdb" "../../oqtane.framework/Oqtane.Server/bin/Debug/net9.0/" cp -rf "../Server/wwwroot/"* "../../oqtane.framework/Oqtane.Server/wwwroot/" diff --git a/Oqtane.Server/wwwroot/css/app.css b/Oqtane.Server/wwwroot/css/app.css index b7143eae..85350ca6 100644 --- a/Oqtane.Server/wwwroot/css/app.css +++ b/Oqtane.Server/wwwroot/css/app.css @@ -267,4 +267,8 @@ app { .text-area-editor > textarea { width: 100%; min-height: 250px; +} + +.app-logo .navbar-brand { + padding: 5px 20px 5px 20px; } \ No newline at end of file diff --git a/Oqtane.Server/wwwroot/js/interop.js b/Oqtane.Server/wwwroot/js/interop.js index b457ad1d..675cebca 100644 --- a/Oqtane.Server/wwwroot/js/interop.js +++ b/Oqtane.Server/wwwroot/js/interop.js @@ -1,11 +1,18 @@ var Oqtane = Oqtane || {}; Oqtane.Interop = { - setCookie: function (name, value, days) { + setCookie: function (name, value, days, secure, sameSite) { var d = new Date(); d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000)); var expires = "expires=" + d.toUTCString(); - document.cookie = name + "=" + value + ";" + expires + ";path=/"; + var cookieString = name + "=" + value + ";" + expires + ";path=/"; + if (secure) { + cookieString += "; secure"; + } + if (sameSite === "Lax" || sameSite === "Strict" || sameSite === "None") { + cookieString += "; SameSite=" + sameSite; + } + document.cookie = cookieString; }, getCookie: function (name) { name = name + "="; @@ -198,7 +205,9 @@ Oqtane.Interop = { } promises.push(new Promise((resolve, reject) => { if (loadjs.isDefined(bundles[b])) { - resolve(true); + loadjs.ready(bundles[b], () => { + resolve(true); + }); } else { loadjs(urls, bundles[b], { @@ -293,41 +302,49 @@ Oqtane.Interop = { }, uploadFiles: function (posturl, folder, id, antiforgerytoken, jwt) { var fileinput = document.getElementById('FileInput_' + id); - var files = fileinput.files; var progressinfo = document.getElementById('ProgressInfo_' + id); var progressbar = document.getElementById('ProgressBar_' + id); if (progressinfo !== null && progressbar !== null) { progressinfo.setAttribute("style", "display: inline;"); + progressinfo.innerHTML = ''; progressbar.setAttribute("style", "width: 100%; display: inline;"); + progressbar.value = 0; } + var files = fileinput.files; + var totalSize = 0; for (var i = 0; i < files.length; i++) { - var FileChunk = []; - var file = files[i]; - var MaxFileSizeMB = 1; - var BufferChunkSize = MaxFileSizeMB * (1024 * 1024); - var FileStreamPos = 0; - var EndPos = BufferChunkSize; - var Size = file.size; + totalSize = totalSize + files[i].size; + } - while (FileStreamPos < Size) { - FileChunk.push(file.slice(FileStreamPos, EndPos)); - FileStreamPos = EndPos; - EndPos = FileStreamPos + BufferChunkSize; + var maxChunkSizeMB = 1; + var bufferChunkSize = maxChunkSizeMB * (1024 * 1024); + var uploadedSize = 0; + + for (var i = 0; i < files.length; i++) { + var fileChunk = []; + var file = files[i]; + var fileStreamPos = 0; + var endPos = bufferChunkSize; + + while (fileStreamPos < file.size) { + fileChunk.push(file.slice(fileStreamPos, endPos)); + fileStreamPos = endPos; + endPos = fileStreamPos + bufferChunkSize; } - var TotalParts = FileChunk.length; - var PartCount = 0; + var totalParts = fileChunk.length; + var partCount = 0; - while (Chunk = FileChunk.shift()) { - PartCount++; - var FileName = file.name + ".part_" + PartCount.toString().padStart(3, '0') + "_" + TotalParts.toString().padStart(3, '0'); + while (chunk = fileChunk.shift()) { + partCount++; + var fileName = file.name + ".part_" + partCount.toString().padStart(3, '0') + "_" + totalParts.toString().padStart(3, '0'); var data = new FormData(); data.append('__RequestVerificationToken', antiforgerytoken); data.append('folder', folder); - data.append('formfile', Chunk, FileName); + data.append('formfile', chunk, fileName); var request = new XMLHttpRequest(); request.open('POST', posturl, true); if (jwt !== "") { @@ -335,28 +352,36 @@ Oqtane.Interop = { request.withCredentials = true; } request.upload.onloadstart = function (e) { - if (progressinfo !== null && progressbar !== null) { - progressinfo.innerHTML = file.name + ' 0%'; - progressbar.value = 0; + if (progressinfo !== null && progressbar !== null && progressinfo.innerHTML === '') { + if (files.length === 1) { + progressinfo.innerHTML = file.name; + } + else { + progressinfo.innerHTML = file.name + ", ..."; + } } }; request.upload.onprogress = function (e) { if (progressinfo !== null && progressbar !== null) { - var percent = Math.ceil((e.loaded / e.total) * 100); - progressinfo.innerHTML = file.name + '[' + PartCount + '] ' + percent + '%'; + var percent = Math.ceil(((uploadedSize + e.loaded) / totalSize) * 100); progressbar.value = (percent / 100); } }; request.upload.onloadend = function (e) { if (progressinfo !== null && progressbar !== null) { - progressinfo.innerHTML = file.name + ' 100%'; - progressbar.value = 1; + uploadedSize = uploadedSize + e.total; + var percent = Math.ceil((uploadedSize / totalSize) * 100); + progressbar.value = (percent / 100); } }; request.upload.onerror = function() { if (progressinfo !== null && progressbar !== null) { - progressinfo.innerHTML = file.name + ' Error: ' + request.statusText; - progressbar.value = 0; + if (files.length === 1) { + progressinfo.innerHTML = file.name + ' Error: ' + request.statusText; + } + else { + progressinfo.innerHTML = ' Error: ' + request.statusText; + } } }; request.send(data); @@ -392,11 +417,20 @@ Oqtane.Interop = { } }, scrollTo: function (top, left, behavior) { - window.scrollTo({ - top: top, - left: left, - behavior: behavior - }); + const modal = document.querySelector('.modal'); + if (modal) { + modal.scrollTo({ + top: top, + left: left, + behavior: behavior + }); + } else { + window.scrollTo({ + top: top, + left: left, + behavior: behavior + }); + } }, scrollToId: function (id) { var element = document.getElementById(id); diff --git a/Oqtane.Shared/Interfaces/IImageService.cs b/Oqtane.Shared/Interfaces/IImageService.cs new file mode 100644 index 00000000..f872b72d --- /dev/null +++ b/Oqtane.Shared/Interfaces/IImageService.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Oqtane.Services +{ + public interface IImageService + { + public string[] GetAvailableFormats(); + + public string CreateImage(string filepath, int width, int height, string mode, string position, string background, string rotate, string format, string imagepath); + } +} diff --git a/Oqtane.Shared/Interfaces/ISearchProvider.cs b/Oqtane.Shared/Interfaces/ISearchProvider.cs index 43981e8d..23713334 100644 --- a/Oqtane.Shared/Interfaces/ISearchProvider.cs +++ b/Oqtane.Shared/Interfaces/ISearchProvider.cs @@ -11,7 +11,7 @@ namespace Oqtane.Services Task> GetSearchResultsAsync(SearchQuery searchQuery); Task SaveSearchContent(SearchContent searchContent, Dictionary siteSettings); - - Task ResetIndex(); + + Task DeleteSearchContent(int siteId); } } diff --git a/Oqtane.Shared/Interfaces/ISearchService.cs b/Oqtane.Shared/Interfaces/ISearchService.cs index 48cef29c..7acf9764 100644 --- a/Oqtane.Shared/Interfaces/ISearchService.cs +++ b/Oqtane.Shared/Interfaces/ISearchService.cs @@ -9,5 +9,7 @@ namespace Oqtane.Services Task GetSearchResultsAsync(SearchQuery searchQuery); Task SaveSearchContentsAsync(List searchContents, Dictionary siteSettings); + + Task DeleteSearchContentsAsync(int siteId); } } diff --git a/Oqtane.Shared/Interfaces/IThemeControl.cs b/Oqtane.Shared/Interfaces/IThemeControl.cs index ef5d9bc2..9cf960fa 100644 --- a/Oqtane.Shared/Interfaces/IThemeControl.cs +++ b/Oqtane.Shared/Interfaces/IThemeControl.cs @@ -16,7 +16,7 @@ namespace Oqtane.Themes string Thumbnail { get; } /// - /// Identifies all panes in a theme ( delimited by "," or ";") - assumed to be a layout if no panes specified + /// Comma delimited list of all panes in a theme /// string Panes { get; } diff --git a/Oqtane.Shared/Models/ExternalLoginProvider.cs b/Oqtane.Shared/Models/ExternalLoginProvider.cs new file mode 100644 index 00000000..8cda01d4 --- /dev/null +++ b/Oqtane.Shared/Models/ExternalLoginProvider.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Oqtane.Models +{ + public class ExternalLoginProvider + { + public string Name { get; set; } + + public Dictionary Settings { get; set; } + } +} diff --git a/Oqtane.Shared/Models/Language.cs b/Oqtane.Shared/Models/Language.cs index 11f4af39..b64a9381 100644 --- a/Oqtane.Shared/Models/Language.cs +++ b/Oqtane.Shared/Models/Language.cs @@ -1,4 +1,3 @@ -using System; using System.ComponentModel.DataAnnotations.Schema; namespace Oqtane.Models @@ -20,11 +19,6 @@ namespace Oqtane.Models /// public int? SiteId { get; set; } - /// - /// Language Name - corresponds to , _not_ - /// - public string Name { get; set; } - /// /// Language / Culture code, like 'en-US' - corresponds to /// @@ -35,10 +29,29 @@ namespace Oqtane.Models /// public bool IsDefault { get; set; } + [NotMapped] + /// + /// Language Name - corresponds to , _not_ + /// + public string Name { get; set; } + [NotMapped] /// /// Version of the satellite assembly /// public string Version { get; set; } + + public Language Clone() + { + return new Language + { + LanguageId = LanguageId, + SiteId = SiteId, + Name = Name, + Code = Code, + IsDefault = IsDefault, + Version = Version + }; + } } } diff --git a/Oqtane.Shared/Models/Module.cs b/Oqtane.Shared/Models/Module.cs index b90e685c..55f31357 100644 --- a/Oqtane.Shared/Models/Module.cs +++ b/Oqtane.Shared/Models/Module.cs @@ -2,6 +2,7 @@ using Oqtane.Shared; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; @@ -28,11 +29,18 @@ namespace Oqtane.Models public string ModuleDefinitionName { get; set; } /// - /// Determines if this Module Instance should be shown on all pages of the current + /// Determines if this module should be shown on all pages of the current /// public bool AllPages { get; set; } - #region IDeletable Properties (note that these are NotMapped and are only used for storing PageModule properties) + + /// + /// Reference to the used for this module. + /// + [NotMapped] + public ModuleDefinition ModuleDefinition { get; set; } + + #region IDeletable Properties [NotMapped] public string DeletedBy { get; set; } @@ -40,17 +48,26 @@ namespace Oqtane.Models public DateTime? DeletedOn { get; set; } [NotMapped] public bool IsDeleted { get; set; } - + #endregion - + + /// + /// list of permissions for this module + /// [NotMapped] public List PermissionList { get; set; } + /// + /// List of settings for this module + /// [NotMapped] public Dictionary Settings { get; set; } #region PageModule properties + /// + /// The id of the PageModule instance + /// [NotMapped] public int PageModuleId { get; set; } @@ -60,24 +77,39 @@ namespace Oqtane.Models [NotMapped] public int PageId { get; set; } + /// + /// Title of the pagemodule instance + /// [NotMapped] public string Title { get; set; } /// - /// The Pane this module is shown in. + /// The pane where this pagemodule instance will be injected on the page /// [NotMapped] public string Pane { get; set; } + /// + /// The order of the pagemodule instance within the Pane + /// [NotMapped] public int Order { get; set; } + /// + /// The container for the pagemodule instance + /// [NotMapped] public string ContainerType { get; set; } + /// + /// Start of when this module is visible. See also + /// [NotMapped] public DateTime? EffectiveDate { get; set; } + /// + /// End of when this module is visible. See also + /// [NotMapped] public DateTime? ExpiryDate { get; set; } @@ -85,38 +117,67 @@ namespace Oqtane.Models #region SiteRouter properties + /// + /// Stores the type name for the module component being rendered + /// [NotMapped] public string ModuleType { get; set; } + + /// + /// The position of the module instance in a pane + /// [NotMapped] public int PaneModuleIndex { get; set; } + + /// + /// The number of modules in a pane + /// [NotMapped] public int PaneModuleCount { get; set; } + + /// + /// A unique id to help determine if a component should be rendered + /// [NotMapped] public Guid RenderId { get; set; } #endregion - #region ModuleDefinition + #region IModuleControl properties + /// - /// Reference to the used for this module. - /// TODO: todoc - unclear if this is always populated + /// The minimum access level to view the component being rendered /// [NotMapped] - public ModuleDefinition ModuleDefinition { get; set; } - - #endregion - - #region IModuleControl properties - [NotMapped] public SecurityAccessLevel SecurityAccessLevel { get; set; } + + /// + /// An optional title for the component + /// [NotMapped] public string ControlTitle { get; set; } + + /// + /// Optional mapping of Url actions to a component + /// [NotMapped] public string Actions { get; set; } + + /// + /// Optionally indicate if a compoent should not be rendered with the default modal admin container + /// [NotMapped] public bool UseAdminContainer { get; set; } + + /// + /// Optionally specify the render mode for the component (overrides the Site setting) + /// [NotMapped] - public string RenderMode{ get; set; } + public string RenderMode { get; set; } + + /// + /// Optionally specify id the component should be prerendered (overrides the Site setting) + /// [NotMapped] public bool? Prerender { get; set; } @@ -140,5 +201,34 @@ namespace Oqtane.Models } #endregion + + public Module Clone() + { + return new Module + { + ModuleId = ModuleId, + SiteId = SiteId, + ModuleDefinitionName = ModuleDefinitionName, + AllPages = AllPages, + PageModuleId = PageModuleId, + PageId = PageId, + Title = Title, + Pane = Pane, + Order = Order, + ContainerType = ContainerType, + EffectiveDate = EffectiveDate, + ExpiryDate = ExpiryDate, + CreatedBy = CreatedBy, + CreatedOn = CreatedOn, + ModifiedBy = ModifiedBy, + ModifiedOn = ModifiedOn, + DeletedBy = DeletedBy, + DeletedOn = DeletedOn, + IsDeleted = IsDeleted, + ModuleDefinition = ModuleDefinition, + PermissionList = PermissionList.ConvertAll(permission => permission.Clone()), + Settings = Settings.ToDictionary(setting => setting.Key, setting => setting.Value) + }; + } } -} + } diff --git a/Oqtane.Shared/Models/Page.cs b/Oqtane.Shared/Models/Page.cs index a1b6d370..bec0347e 100644 --- a/Oqtane.Shared/Models/Page.cs +++ b/Oqtane.Shared/Models/Page.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; @@ -75,33 +76,68 @@ namespace Oqtane.Models public string BodyContent { get; set; } /// - /// Icon file for this page. - /// TODO: unclear what this is for, and what icon library is used. Probably FontAwesome? + /// Icon class name for this page /// public string Icon { get; set; } + + /// + /// Indicates if this page should be included in navigation menu + /// public bool IsNavigation { get; set; } + + /// + /// Indicates if this page should be clickable in navigation menu + /// public bool IsClickable { get; set; } - public int? UserId { get; set; } + /// - /// Start of when this assignment is valid. See also + /// Indicates if page is personalizable ie. allows users to create custom versions of the page /// - public DateTime? EffectiveDate { get; set; } - /// - /// End of when this assignment is valid. See also - /// - public DateTime? ExpiryDate { get; set; } public bool IsPersonalizable { get; set; } - #region IDeletable Properties - - public string DeletedBy { get; set; } - public DateTime? DeletedOn { get; set; } - public bool IsDeleted { get; set; } - - #endregion + /// + /// Reference to the user who owns the personalized page + /// + public int? UserId { get; set; } /// - /// List of Pane-names which this Page has. + /// Start of when this page is visible. See also + /// + public DateTime? EffectiveDate { get; set; } + + /// + /// End of when this page is visible. See also + /// + public DateTime? ExpiryDate { get; set; } + + /// + /// The hierarchical level of the page + /// + [NotMapped] + public int Level { get; set; } + + /// + /// Determines if there are sub-pages. True if this page has sub-pages. + /// + [NotMapped] + public bool HasChildren { get; set; } + + /// + /// List of permissions for this page + /// + [NotMapped] + public List PermissionList { get; set; } + + /// + /// List of settings for this page + /// + [NotMapped] + public Dictionary Settings { get; set; } + + #region SiteRouter properties + + /// + /// List of Pane names for the Theme assigned to this page /// [NotMapped] public List Panes { get; set; } @@ -112,20 +148,15 @@ namespace Oqtane.Models [NotMapped] public List Resources { get; set; } - [NotMapped] - public List PermissionList { get; set; } + #endregion - [NotMapped] - public Dictionary Settings { get; set; } + #region IDeletable Properties - [NotMapped] - public int Level { get; set; } + public string DeletedBy { get; set; } + public DateTime? DeletedOn { get; set; } + public bool IsDeleted { get; set; } - /// - /// Determines if there are sub-pages. True if this page has sub-pages. - /// - [NotMapped] - public bool HasChildren { get; set; } + #endregion #region Deprecated Properties @@ -152,5 +183,42 @@ namespace Oqtane.Models } #endregion + + public Page Clone() + { + return new Page + { + PageId = PageId, + SiteId = SiteId, + Path = Path, + ParentId = ParentId, + Name = Name, + Title = Title, + Order = Order, + Url = Url, + ThemeType = ThemeType, + DefaultContainerType = DefaultContainerType, + HeadContent = HeadContent, + BodyContent = BodyContent, + Icon = Icon, + IsNavigation = IsNavigation, + IsClickable = IsClickable, + UserId = UserId, + IsPersonalizable = IsPersonalizable, + EffectiveDate = EffectiveDate, + ExpiryDate = ExpiryDate, + Level = Level, + HasChildren = HasChildren, + CreatedBy = CreatedBy, + CreatedOn = CreatedOn, + ModifiedBy = ModifiedBy, + ModifiedOn = ModifiedOn, + DeletedBy = DeletedBy, + DeletedOn = DeletedOn, + IsDeleted = IsDeleted, + PermissionList = PermissionList.ConvertAll(permission => permission.Clone()), + Settings = Settings.ToDictionary(setting => setting.Key, setting => setting.Value) + }; + } } } diff --git a/Oqtane.Shared/Models/Permission.cs b/Oqtane.Shared/Models/Permission.cs index 1448e039..6323d113 100644 --- a/Oqtane.Shared/Models/Permission.cs +++ b/Oqtane.Shared/Models/Permission.cs @@ -101,17 +101,22 @@ namespace Oqtane.Models IsAuthorized = isAuthorized; } - public Permission Clone(Permission permission) + public Permission Clone() { return new Permission { - SiteId = permission.SiteId, - EntityName = permission.EntityName, - EntityId = permission.EntityId, - PermissionName = permission.PermissionName, - RoleName = permission.RoleName, - UserId = permission.UserId, - IsAuthorized = permission.IsAuthorized + SiteId = SiteId, + EntityName = EntityName, + EntityId = EntityId, + PermissionName = PermissionName, + RoleName = RoleName, + RoleId = RoleId, + UserId = UserId, + IsAuthorized = IsAuthorized, + CreatedBy = CreatedBy, + CreatedOn = CreatedOn, + ModifiedBy = ModifiedBy, + ModifiedOn = ModifiedOn }; } diff --git a/Oqtane.Shared/Models/RequestCulture.cs b/Oqtane.Shared/Models/RequestCulture.cs new file mode 100644 index 00000000..12eaaa7e --- /dev/null +++ b/Oqtane.Shared/Models/RequestCulture.cs @@ -0,0 +1,67 @@ +using System.Globalization; +using System; + +namespace Oqtane.Models +{ + /// + /// Culture information describing a Culture + /// + public class RequestCulture + { + /// + /// Creates a new object with its and + /// properties set to the same value. + /// + /// The for the request. + public RequestCulture(CultureInfo culture) + : this(culture, culture) + { + } + + /// + /// Creates a new object with its and + /// properties set to the same value. + /// + /// The culture for the request. + public RequestCulture(string culture) + : this(culture, culture) + { + } + + /// + /// Creates a new object with its and + /// properties set to the respective values provided. + /// + /// The culture for the request to be used for formatting. + /// The culture for the request to be used for text, i.e. language. + public RequestCulture(string culture, string uiCulture) + : this(new CultureInfo(culture), new CultureInfo(uiCulture)) + { + } + + /// + /// Creates a new object with its and + /// properties set to the respective values provided. + /// + /// The for the request to be used for formatting. + /// The for the request to be used for text, i.e. language. + public RequestCulture(CultureInfo culture, CultureInfo uiCulture) + { + ArgumentNullException.ThrowIfNull(culture); + ArgumentNullException.ThrowIfNull(uiCulture); + + Culture = culture; + UICulture = uiCulture; + } + + /// + /// Gets the for the request to be used for formatting. + /// + public CultureInfo Culture { get; } + + /// + /// Gets the for the request to be used for text, i.e. language; + /// + public CultureInfo UICulture { get; } + } +} diff --git a/Oqtane.Shared/Models/Site.cs b/Oqtane.Shared/Models/Site.cs index d5508858..b108cea2 100644 --- a/Oqtane.Shared/Models/Site.cs +++ b/Oqtane.Shared/Models/Site.cs @@ -187,47 +187,47 @@ namespace Oqtane.Models [NotMapped] public List Themes { get; set; } - public Site Clone(Site site) + public Site Clone() { return new Site { - SiteId = site.SiteId, - TenantId = site.TenantId, - Name = site.Name, - LogoFileId = site.LogoFileId, - FaviconFileId = site.FaviconFileId, - DefaultThemeType = site.DefaultThemeType, - DefaultContainerType = site.DefaultContainerType, - AdminContainerType = site.AdminContainerType, - PwaIsEnabled = site.PwaIsEnabled, - PwaAppIconFileId = site.PwaAppIconFileId, - PwaSplashIconFileId = site.PwaSplashIconFileId, - AllowRegistration = site.AllowRegistration, - VisitorTracking = site.VisitorTracking, - CaptureBrokenUrls = site.CaptureBrokenUrls, - SiteGuid = site.SiteGuid, - RenderMode = site.RenderMode, - Runtime = site.Runtime, - Prerender = site.Prerender, - Hybrid = site.Hybrid, - Version = site.Version, - HomePageId = site.HomePageId, - HeadContent = site.HeadContent, - BodyContent = site.BodyContent, - IsDeleted = site.IsDeleted, - DeletedBy = site.DeletedBy, - DeletedOn = site.DeletedOn, - ImageFiles = site.ImageFiles, - UploadableFiles = site.UploadableFiles, - SiteTemplateType = site.SiteTemplateType, - CreatedBy = site.CreatedBy, - CreatedOn = site.CreatedOn, - ModifiedBy = site.ModifiedBy, - ModifiedOn = site.ModifiedOn, - Settings = site.Settings.ToDictionary(), - Pages = site.Pages.ToList(), - Languages = site.Languages.ToList(), - Themes = site.Themes.ToList() + SiteId = SiteId, + TenantId = TenantId, + Name = Name, + LogoFileId = LogoFileId, + FaviconFileId = FaviconFileId, + DefaultThemeType = DefaultThemeType, + DefaultContainerType = DefaultContainerType, + AdminContainerType = AdminContainerType, + PwaIsEnabled = PwaIsEnabled, + PwaAppIconFileId = PwaAppIconFileId, + PwaSplashIconFileId = PwaSplashIconFileId, + AllowRegistration = AllowRegistration, + VisitorTracking = VisitorTracking, + CaptureBrokenUrls = CaptureBrokenUrls, + SiteGuid = SiteGuid, + RenderMode = RenderMode, + Runtime = Runtime, + Prerender = Prerender, + Hybrid = Hybrid, + Version = Version, + HomePageId = HomePageId, + HeadContent = HeadContent, + BodyContent = BodyContent, + IsDeleted = IsDeleted, + DeletedBy = DeletedBy, + DeletedOn = DeletedOn, + ImageFiles = ImageFiles, + UploadableFiles = UploadableFiles, + SiteTemplateType = SiteTemplateType, + CreatedBy = CreatedBy, + CreatedOn = CreatedOn, + ModifiedBy = ModifiedBy, + ModifiedOn = ModifiedOn, + Settings = Settings.ToDictionary(setting => setting.Key, setting => setting.Value), + Pages = Pages.ConvertAll(page => page.Clone()), + Languages = Languages.ConvertAll(language => language.Clone()), + Themes = Themes }; } diff --git a/Oqtane.Shared/Models/User.cs b/Oqtane.Shared/Models/User.cs index aca9dd9d..d5009fb5 100644 --- a/Oqtane.Shared/Models/User.cs +++ b/Oqtane.Shared/Models/User.cs @@ -59,6 +59,12 @@ namespace Oqtane.Models /// public DateTime? TwoFactorExpiry { get; set; } + /// + /// A token indicating if a user's security properties have been modified + /// + [NotMapped] + public string SecurityStamp { get; set; } + /// /// Reference to the this user belongs to. /// @@ -66,8 +72,7 @@ namespace Oqtane.Models public int SiteId { get; set; } /// - /// Role names this user has. - /// TODO: todoc - is this comma separated? + /// Semi-colon delimited list of role names for the user /// [NotMapped] public string Roles { get; set; } diff --git a/Oqtane.Shared/Models/UserRole.cs b/Oqtane.Shared/Models/UserRole.cs index b3597ae2..2c891126 100644 --- a/Oqtane.Shared/Models/UserRole.cs +++ b/Oqtane.Shared/Models/UserRole.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel.DataAnnotations.Schema; namespace Oqtane.Models { @@ -26,11 +27,18 @@ namespace Oqtane.Models /// Start of when this assignment is valid. See also /// public DateTime? EffectiveDate { get; set; } + /// /// End of when this assignment is valid. See also /// public DateTime? ExpiryDate { get; set; } + /// + /// Indicates that the User Security Stamp should not be updated when this user role is added or updated + /// + [NotMapped] + public bool IgnoreSecurityStamp { get; set; } + /// /// Direct reference to the object. /// TODO: todoc - is this always populated? diff --git a/Oqtane.Shared/Models/UserValidateResult.cs b/Oqtane.Shared/Models/UserValidateResult.cs new file mode 100644 index 00000000..d531ab82 --- /dev/null +++ b/Oqtane.Shared/Models/UserValidateResult.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Oqtane.Models +{ + public class UserValidateResult + { + public bool Succeeded { get; set; } + + public IDictionary Errors { get; set; } = new Dictionary(); + } +} diff --git a/Oqtane.Shared/Oqtane.Shared.csproj b/Oqtane.Shared/Oqtane.Shared.csproj index 9e5ae9d8..c3854210 100644 --- a/Oqtane.Shared/Oqtane.Shared.csproj +++ b/Oqtane.Shared/Oqtane.Shared.csproj @@ -1,9 +1,9 @@ - net8.0 + net9.0 Debug;Release - 5.2.2 + 6.0.0 Oqtane Shaun Walker .NET Foundation @@ -11,7 +11,7 @@ .NET Foundation https://www.oqtane.org https://github.com/oqtane/oqtane.framework/blob/dev/LICENSE - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 https://github.com/oqtane/oqtane.framework Git Oqtane @@ -19,11 +19,11 @@ - - - + + + - + diff --git a/Oqtane.Shared/Security/UserSecurity.cs b/Oqtane.Shared/Security/UserSecurity.cs index 427503d7..4d954037 100644 --- a/Oqtane.Shared/Security/UserSecurity.cs +++ b/Oqtane.Shared/Security/UserSecurity.cs @@ -99,8 +99,8 @@ namespace Oqtane.Security if (alias != null && user != null && !user.IsDeleted) { identity.AddClaim(new Claim(ClaimTypes.Name, user.Username)); - identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.UserId.ToString())); - identity.AddClaim(new Claim("sitekey", alias.SiteKey)); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.UserId.ToString())); + identity.AddClaim(new Claim(Constants.SiteKeyClaimType, alias.SiteKey)); if (user.Roles.Contains(RoleNames.Host)) { // host users are site admins by default @@ -115,6 +115,7 @@ namespace Oqtane.Security identity.AddClaim(new Claim(ClaimTypes.Role, role)); } } + identity.AddClaim(new Claim(Constants.SecurityStampClaimType, user.SecurityStamp)); } return identity; } diff --git a/Oqtane.Shared/Shared/Constants.cs b/Oqtane.Shared/Shared/Constants.cs index dc76786d..aa83db6c 100644 --- a/Oqtane.Shared/Shared/Constants.cs +++ b/Oqtane.Shared/Shared/Constants.cs @@ -4,8 +4,8 @@ namespace Oqtane.Shared { public class Constants { - public static readonly string Version = "5.2.2"; - public const string ReleaseVersions = "1.0.0,1.0.1,1.0.2,1.0.3,1.0.4,2.0.0,2.0.1,2.0.2,2.1.0,2.2.0,2.3.0,2.3.1,3.0.0,3.0.1,3.0.2,3.0.3,3.1.0,3.1.1,3.1.2,3.1.3,3.1.4,3.2.0,3.2.1,3.3.0,3.3.1,3.4.0,3.4.1,3.4.2,3.4.3,4.0.0,4.0.1,4.0.2,4.0.3,4.0.4,4.0.5,4.0.6,5.0.0,5.0.1,5.0.2,5.0.3,5.1.0,5.1.1,5.1.2,5.2.0,5.2.1,5.2.2"; + public static readonly string Version = "6.0.0"; + public const string ReleaseVersions = "1.0.0,1.0.1,1.0.2,1.0.3,1.0.4,2.0.0,2.0.1,2.0.2,2.1.0,2.2.0,2.3.0,2.3.1,3.0.0,3.0.1,3.0.2,3.0.3,3.1.0,3.1.1,3.1.2,3.1.3,3.1.4,3.2.0,3.2.1,3.3.0,3.3.1,3.4.0,3.4.1,3.4.2,3.4.3,4.0.0,4.0.1,4.0.2,4.0.3,4.0.4,4.0.5,4.0.6,5.0.0,5.0.1,5.0.2,5.0.3,5.1.0,5.1.1,5.1.2,5.2.0,5.2.1,5.2.2,5.2.3,5.2.4,6.0.0"; public const string PackageId = "Oqtane.Framework"; public const string ClientId = "Oqtane.Client"; public const string UpdaterPackageId = "Oqtane.Updater"; @@ -67,6 +67,9 @@ namespace Oqtane.Shared public static readonly string AntiForgeryTokenHeaderName = "X-XSRF-TOKEN-HEADER"; public static readonly string AntiForgeryTokenCookieName = "X-XSRF-TOKEN-COOKIE"; + public static readonly string SecurityStampClaimType = "AspNet.Identity.SecurityStamp"; + public static readonly string SiteKeyClaimType = "Oqtane.Identity.SiteKey"; + public static readonly string DefaultVisitorFilter = "bot,crawler,slurp,spider,(none),??"; public static readonly string HttpContextAliasKey = "Alias"; diff --git a/Oqtane.Shared/Shared/CookieRequestCultureProvider.cs b/Oqtane.Shared/Shared/CookieRequestCultureProvider.cs new file mode 100644 index 00000000..040d43b3 --- /dev/null +++ b/Oqtane.Shared/Shared/CookieRequestCultureProvider.cs @@ -0,0 +1,89 @@ +using System; +using Oqtane.Models; + +namespace Oqtane.Shared +{ + public class CookieRequestCultureProvider + { + private const char _cookieSeparator = '|'; + private const string _culturePrefix = "c="; + private const string _uiCulturePrefix = "uic="; + + /// + /// Represent the default cookie name used to track the user's preferred culture information, which is ".AspNetCore.Culture". + /// + public static readonly string DefaultCookieName = ".AspNetCore.Culture"; + + /// + /// The name of the cookie that contains the user's preferred culture information. + /// Defaults to . + /// + public string CookieName { get; set; } = DefaultCookieName; + + /// + /// Creates a string representation of a for placement in a cookie. + /// + /// The . + /// The cookie value. + public static string MakeCookieValue(RequestCulture requestCulture) + { + ArgumentNullException.ThrowIfNull(requestCulture); + + return string.Join(_cookieSeparator, + $"{_culturePrefix}{requestCulture.Culture.Name}", + $"{_uiCulturePrefix}{requestCulture.UICulture.Name}"); + } + + /// + /// Parses a from the specified cookie value. + /// Returns null if parsing fails. + /// + /// The cookie value to parse. + /// The or null if parsing fails. + public static RequestCulture ParseCookieValue(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + Span parts = stackalloc Range[3]; + var valueSpan = value.AsSpan(); + if (valueSpan.Split(parts, _cookieSeparator, StringSplitOptions.RemoveEmptyEntries) != 2) + { + return null; + } + + var potentialCultureName = valueSpan[parts[0]]; + var potentialUICultureName = valueSpan[parts[1]]; + + if (!potentialCultureName.StartsWith(_culturePrefix, StringComparison.Ordinal) || ! + potentialUICultureName.StartsWith(_uiCulturePrefix, StringComparison.Ordinal)) + { + return null; + } + + var cultureName = potentialCultureName.Slice(_culturePrefix.Length); + var uiCultureName = potentialUICultureName.Slice(_uiCulturePrefix.Length); + + if (cultureName.IsEmpty && uiCultureName.IsEmpty) + { + // No values specified for either so no match + return null; + } + + if (!cultureName.IsEmpty && uiCultureName.IsEmpty) + { + // Value for culture but not for UI culture so default to culture value for both + uiCultureName = cultureName; + } + else if (cultureName.IsEmpty && !uiCultureName.IsEmpty) + { + // Value for UI culture but not for culture so default to UI culture value for both + cultureName = uiCultureName; + } + + return new RequestCulture(cultureName.ToString(), uiCultureName.ToString()); + } + } +} diff --git a/Oqtane.Shared/Shared/ExternalLoginProviders.cs b/Oqtane.Shared/Shared/ExternalLoginProviders.cs new file mode 100644 index 00000000..8b62bad9 --- /dev/null +++ b/Oqtane.Shared/Shared/ExternalLoginProviders.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using Oqtane.Models; + +namespace Oqtane.Shared +{ + public class ExternalLoginProviders + { + public static List Providers + { + get + { + var providers = new List + { + new ExternalLoginProvider + { + Name = "", + Settings = new Dictionary() + }, + // OIDC + new ExternalLoginProvider + { + Name = "Microsoft Entra", + Settings = new Dictionary() + { + { "ExternalLogin:ProviderUrl", "https://entra.microsoft.com" }, + { "ExternalLogin:ProviderType", "oidc" }, + { "ExternalLogin:ProviderName", "Microsoft Entra" }, + { "ExternalLogin:Authority", "https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0" }, + { "ExternalLogin:ClientId", "YOUR CLIENT ID" }, + { "ExternalLogin:ClientSecret", "YOUR CLIENT SECRET" } + } + }, + new ExternalLoginProvider + { + Name = "Auth0 (by Okta)", + Settings = new Dictionary() + { + { "ExternalLogin:ProviderUrl", "https://auth0.com/docs/get-started" }, + { "ExternalLogin:ProviderType", "oidc" }, + { "ExternalLogin:ProviderName", "Auth0" }, + { "ExternalLogin:Authority", "YOUR DOMAIN" }, + { "ExternalLogin:ClientId", "YOUR CLIENT ID" }, + { "ExternalLogin:ClientSecret", "YOUR CLIENT SECRET" } + } + }, + // OAuth2 + new ExternalLoginProvider + { + Name = "GitHub", + Settings = new Dictionary() + { + { "ExternalLogin:ProviderUrl", "https://github.com/settings/developers#oauth-apps" }, + { "ExternalLogin:ProviderType", "oauth2" }, + { "ExternalLogin:ProviderName", "GitHub" }, + { "ExternalLogin:AuthorizationUrl", "https://github.com/login/oauth/authorize" }, + { "ExternalLogin:TokenUrl", "https://github.com/login/oauth/access_token" }, + { "ExternalLogin:UserInfoUrl", "https://api.github.com/user/emails" }, + { "ExternalLogin:ClientId", "YOUR CLIENT ID" }, + { "ExternalLogin:ClientSecret", "YOUR CLIENT SECRET" }, + { "ExternalLogin:Scopes", "user:email" }, + { "ExternalLogin:IdentifierClaimType", "email" }, + { "ExternalLogin:DomainFilter", "!users.noreply.github.com" } + } + }, + new ExternalLoginProvider + { + Name = "Facebook", + Settings = new Dictionary() + { + { "ExternalLogin:ProviderUrl", "https://developers.facebook.com/apps/" }, + { "ExternalLogin:ProviderType", "oauth2" }, + { "ExternalLogin:ProviderName", "Facebook" }, + { "ExternalLogin:AuthorizationUrl", "https://www.facebook.com/v18.0/dialog/oauth" }, + { "ExternalLogin:TokenUrl", "https://graph.facebook.com/v18.0/oauth/access_token" }, + { "ExternalLogin:UserInfoUrl", "https://graph.facebook.com/v18.0/me" }, + { "ExternalLogin:ClientId", "YOUR CLIENT ID" }, + { "ExternalLogin:ClientSecret", "YOUR CLIENT SECRET" }, + { "ExternalLogin:Scopes", "public_profile" }, + { "ExternalLogin:IdentifierClaimType", "id" } + } + } + }; + + return providers.OrderBy(item => item.Name).ToList(); + } + } + } +} diff --git a/Oqtane.Updater/Oqtane.Updater.csproj b/Oqtane.Updater/Oqtane.Updater.csproj index 3d1adf6c..8f111c0b 100644 --- a/Oqtane.Updater/Oqtane.Updater.csproj +++ b/Oqtane.Updater/Oqtane.Updater.csproj @@ -1,9 +1,9 @@ - net8.0 + net9.0 Exe - 5.2.2 + 6.0.0 Oqtane Shaun Walker .NET Foundation @@ -11,7 +11,7 @@ .NET Foundation https://www.oqtane.org https://github.com/oqtane/oqtane.framework/blob/dev/LICENSE - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0 https://github.com/oqtane/oqtane.framework Git Oqtane diff --git a/README.md b/README.md index 89c79d8b..4b1adde8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Latest Release -[5.2.1](https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.1) was released on August 22, 2024 and is a maintenance release including 41 pull requests by 5 different contributors, pushing the total number of project commits all-time to over 5700. The Oqtane framework continues to evolve at a rapid pace to meet the needs of .NET developers. +[6.0.0](https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0) was released on November 14, 2024 and is a major release including 39 pull requests by 6 different contributors, pushing the total number of project commits all-time to over 6000. The Oqtane framework continues to evolve at a rapid pace to meet the needs of .NET developers. [![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Foqtane%2Foqtane.framework%2Fmaster%2Fazuredeploy.json) @@ -14,13 +14,13 @@ Oqtane is being developed based on some fundamental principles which are outline Please note that this project is owned by the .NET Foundation and is governed by the **[.NET Foundation Contributor Covenant Code of Conduct](https://dotnetfoundation.org/code-of-conduct)** -# Getting Started (Version 5.x) +# Getting Started (Version 6.x) **Installing using source code from the Dev/Master branch:** -- Install **[.NET 8.0.8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)**. +- Install **[.NET 9.0.0 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)**. -- Install the latest edition (v17.9 or higher) of [Visual Studio 2022](https://visualstudio.microsoft.com/downloads) with the **ASP.NET and web development** workload enabled. Oqtane works with ALL editions of Visual Studio from Community to Enterprise. If you wish to use LocalDB for development ( not a requirement as Oqtane supports SQLite, mySQL, and PostgreSQL ) you must also install the **Data storage and processing**. +- Install the latest edition (v17.12 or higher) of [Visual Studio 2022](https://visualstudio.microsoft.com/downloads) with the **ASP.NET and web development** workload enabled. Oqtane works with ALL editions of Visual Studio from Community to Enterprise. If you wish to use LocalDB for development ( not a requirement as Oqtane supports SQLite, mySQL, and PostgreSQL ) you must also install the **Data storage and processing**. - Clone (or download) the Oqtane Master or Dev branch source code to your local system. @@ -75,13 +75,28 @@ Explore and enhance your Oqtane experience by visiting the Oqtane Marketplace. D # Documentation There is a separate [Documentation repository](https://github.com/oqtane/oqtane.docs) which contains a variety of types of documentation for Oqtane, including API documentation that is auto generated using Docfx. The contents of the repository is published to Githib Pages and is available at [https://docs.oqtane.org](https://docs.oqtane.org/) +# Join the Community + +Connect with other developers, get support, and share ideas by joining the Oqtane community on Discord! + +[![Join our Discord](https://img.shields.io/badge/Join%20Discord-7289DA?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/BnPny88avK) + # Roadmap This project is open source, and therefore is a work in progress... + +[6.0.0](https://github.com/oqtane/oqtane.framework/releases/tag/v6.0.0) (Nov 14, 2024) +- [x] Migration to .NET 9 -Backlog (TBD) -- [ ] Azure Autoscale support (ie. web farm) -- [ ] Folder Providers -- [ ] Generative AI Integration +[5.2.4](https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.4) (Oct 17, 2024) +- [x] Stabilization improvements + +[5.2.3](https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.3) (Sep 23, 2024) +- [x] Stabilization improvements + +[5.2.2](https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2) (Sep 23, 2024) +- [x] Stabilization improvements +- [x] Support for Security Stamp to faciliate Logout Everywhere +- [x] Role synchronization from External Login identity providers [5.2.1](https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.1) (Aug 22, 2024) - [x] Stabilization improvements