diff --git a/.gitignore b/.gitignore index bd2d7057..69863764 100644 --- a/.gitignore +++ b/.gitignore @@ -22,10 +22,14 @@ Oqtane.Server/Packages Oqtane.Server/wwwroot/Content Oqtane.Server/wwwroot/Packages/*.log -Oqtane.Server/wwwroot/Modules +Oqtane.Server/wwwroot/Modules/* !Oqtane.Server/wwwroot/Modules/Oqtane.Modules.* !Oqtane.Server/wwwroot/Modules/Templates +Oqtane.Server/wwwroot/Modules/Templates/* +!Oqtane.Server/wwwroot/Modules/Templates/External -Oqtane.Server/wwwroot/Themes +Oqtane.Server/wwwroot/Themes/* !Oqtane.Server/wwwroot/Themes/Oqtane.Themes.* !Oqtane.Server/wwwroot/Themes/Templates +Oqtane.Server/wwwroot/Themes/Templates/* +Oqtane.Server/wwwroot/Themes/Templates/External diff --git a/Oqtane.Client/Installer/Installer.razor b/Oqtane.Client/Installer/Installer.razor index 2ce996de..94f9cc39 100644 --- a/Oqtane.Client/Installer/Installer.razor +++ b/Oqtane.Client/Installer/Installer.razor @@ -162,7 +162,7 @@ protected override async Task OnInitializedAsync() { // include CSS - var content = ""; + var content = $""; SiteState.AppendHeadContent(content); _togglePassword = SharedLocalizer["ShowPassword"]; @@ -217,7 +217,7 @@ { // include JavaScript var interop = new Interop(JSRuntime); - await interop.IncludeScript("", "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js", "sha512-VK2zcvntEufaimc+efOYi622VN5ZacdnufnmX7zIhCPmjhKnOi9ZDMtg1/ug5l183f19gG1/cBstPO4D8N/Img==", "anonymous", "", "head"); + await interop.IncludeScript("", Constants.BootstrapScriptUrl, Constants.BootstrapScriptIntegrity, "anonymous", "", "head"); } } 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/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 ffe021d9..d2ab823e 100644 --- a/Oqtane.Client/Modules/Admin/Modules/Settings.razor +++ b/Oqtane.Client/Modules/Admin/Modules/Settings.razor @@ -9,129 +9,133 @@ @inject IStringLocalizer Localizer @inject IStringLocalizer SharedLocalizer -
- - - @if (_containers != null) - { -
-
- -
- +@if (_initialized) +{ + + + + @if (_containers != null) + { +
+
+ +
+ +
-
-
- -
- +
+ +
+ +
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- + @foreach (string pane in PageState.Page.Panes) { - foreach (Page p in _pages) + + } + +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + +
-
- } - - - @if (_permissions != null) - { -
-
- + } + + + @if (_permissions != null) + { +
+
+ +
-
+ } + + @if (_moduleSettingsType != null) + { + + @ModuleSettingsComponent + } - - @if (_moduleSettingsType != null) - { - - @ModuleSettingsComponent - - } - @if (_containerSettingsType != null) - { - - @ContainerSettingsComponent - - } - -
- - @SharedLocalizer["Cancel"] -
-
- - + @if (_containerSettingsType != null) + { + + @ContainerSettingsComponent + + } + +
+ + @SharedLocalizer["Cancel"] +
+
+ + +} @code { public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Edit; + private bool _initialized = false; private ElementReference form; private bool validated = false; private List _containers = new List(); @@ -163,7 +167,6 @@ { SetModuleTitle(Localizer["ModuleSettings.Title"]); - _module = ModuleState.ModuleDefinition.Name; _title = ModuleState.Title; _moduleSettingsTitle = Localizer["ModuleSettings.Heading"]; _pane = ModuleState.Pane; @@ -182,6 +185,7 @@ if (ModuleState.ModuleDefinition != null) { + _module = ModuleState.ModuleDefinition.Name; _permissionNames = ModuleState.ModuleDefinition?.PermissionNames; if (!string.IsNullOrEmpty(ModuleState.ModuleDefinition.SettingsType)) @@ -231,6 +235,8 @@ }; } } + + _initialized = true; } private async Task SaveModule() 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/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/SearchResults/Index.razor b/Oqtane.Client/Modules/Admin/SearchResults/Index.razor index e69a0f63..80de5e11 100644 --- a/Oqtane.Client/Modules/Admin/SearchResults/Index.razor +++ b/Oqtane.Client/Modules/Admin/SearchResults/Index.razor @@ -7,6 +7,7 @@ @inject ISettingService SettingService @inject IStringLocalizer Localizer @inject IStringLocalizer SharedLocalizer +@attribute [StreamRendering] // attribute allows the progress indicator to be displayed
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/Add.razor b/Oqtane.Client/Modules/Admin/Users/Add.razor index 805155cb..537a3bac 100644 --- a/Oqtane.Client/Modules/Admin/Users/Add.razor +++ b/Oqtane.Client/Modules/Admin/Users/Add.razor @@ -14,7 +14,6 @@ @if (profiles != null) { -
@@ -22,24 +21,6 @@
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
@@ -123,12 +104,7 @@ @code { private bool _initialized = false; - private string _passwordrequirements; private string _username = string.Empty; - private string _password = string.Empty; - private string _passwordtype = "password"; - private string _togglepassword = string.Empty; - private string _confirm = string.Empty; private string _email = string.Empty; private string _displayname = string.Empty; private string _notify = "True"; @@ -142,8 +118,6 @@ { try { - _passwordrequirements = await UserService.GetPasswordRequirementsAsync(PageState.Site.SiteId); - _togglepassword = SharedLocalizer["ShowPassword"]; profiles = await ProfileService.GetProfilesAsync(ModuleState.SiteId); settings = new Dictionary(); _initialized = true; @@ -169,39 +143,32 @@ { try { - if (_username != string.Empty && _password != string.Empty && _confirm != string.Empty && _email != string.Empty) + if (_username != string.Empty && _email != string.Empty) { - if (_password == _confirm) + if (ValidateProfiles()) { - if (ValidateProfiles()) + var user = new User(); + user.SiteId = PageState.Site.SiteId; + user.Username = _username; + user.Password = ""; // will be auto generated + user.Email = _email; + user.DisplayName = string.IsNullOrWhiteSpace(_displayname) ? _username : _displayname; + user.PhotoFileId = null; + user.SuppressNotification = !bool.Parse(_notify); + + user = await UserService.AddUserAsync(user); + + if (user != null) { - var user = new User(); - user.SiteId = PageState.Site.SiteId; - user.Username = _username; - user.Password = _password; - user.Email = _email; - user.DisplayName = string.IsNullOrWhiteSpace(_displayname) ? _username : _displayname; - user.PhotoFileId = null; - user.SuppressNotification = !bool.Parse(_notify); - - user = await UserService.AddUserAsync(user); - - if (user != null) - { - await SettingService.UpdateUserSettingsAsync(settings, user.UserId); - await logger.LogInformation("User Created {User}", user); - NavigationManager.NavigateTo(NavigateUrl()); - } - else - { - await logger.LogError("Error Adding User {Username} {Email}", _username, _email); - AddModuleMessage(Localizer["Error.User.AddCheckPass"], MessageType.Error); - } + await SettingService.UpdateUserSettingsAsync(settings, user.UserId); + await logger.LogInformation("User Created {User}", user); + NavigationManager.NavigateTo(NavigateUrl()); + } + else + { + await logger.LogError("Error Adding User {Username} {Email}", _username, _email); + AddModuleMessage(Localizer["Error.User.AddCheckPass"], MessageType.Error); } - } - else - { - AddModuleMessage(Localizer["Message.Password.NoMatch"], MessageType.Warning); } } else @@ -252,18 +219,4 @@ var value = (string)e.Value; settings = SettingService.SetSetting(settings, SettingName, value); } - - private void TogglePassword() - { - if (_passwordtype == "password") - { - _passwordtype = "text"; - _togglepassword = SharedLocalizer["HidePassword"]; - } - else - { - _passwordtype = "password"; - _togglepassword = SharedLocalizer["ShowPassword"]; - } - } } diff --git a/Oqtane.Client/Modules/Admin/Users/Index.razor b/Oqtane.Client/Modules/Admin/Users/Index.razor index a04be4c3..fa493c36 100644 --- a/Oqtane.Client/Modules/Admin/Users/Index.razor +++ b/Oqtane.Client/Modules/Admin/Users/Index.razor @@ -333,12 +333,29 @@ else
- +
-
+
+ +
+ +
+
+
+ +
+
+ +
+
+
+
@@ -457,6 +474,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; @@ -521,6 +540,8 @@ else _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"); @@ -614,7 +635,9 @@ else 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: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); diff --git a/Oqtane.Client/Modules/Controls/ActionDialog.razor b/Oqtane.Client/Modules/Controls/ActionDialog.razor index 6fc51e15..b39b4f27 100644 --- a/Oqtane.Client/Modules/Controls/ActionDialog.razor +++ b/Oqtane.Client/Modules/Controls/ActionDialog.razor @@ -173,6 +173,12 @@ else _editmode = bool.Parse(EditMode); } + Text = Localize(nameof(Text), Text); + Header = Localize(nameof(Header), Header); + Message = Localize(nameof(Message), Message); + + _openText = Text; + if (!string.IsNullOrEmpty(IconName)) { if (IconOnly) @@ -190,12 +196,7 @@ else _openIconSpan = $"{(IconOnly ? "" : " ")}"; _iconSpan = $" "; } - - Text = Localize(nameof(Text), Text); - Header = Localize(nameof(Header), Header); - Message = Localize(nameof(Message), Message); - - _openText = Text; + _permissions = (PermissionList == null) ? ModuleState.PermissionList : PermissionList; _authorized = IsAuthorized(); diff --git a/Oqtane.Client/Modules/Controls/FileManager.razor b/Oqtane.Client/Modules/Controls/FileManager.razor index b7c603ff..a52f359e 100644 --- a/Oqtane.Client/Modules/Controls/FileManager.razor +++ b/Oqtane.Client/Modules/Controls/FileManager.razor @@ -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/PermissionGrid.razor b/Oqtane.Client/Modules/Controls/PermissionGrid.razor index 99379903..6bd700bd 100644 --- a/Oqtane.Client/Modules/Controls/PermissionGrid.razor +++ b/Oqtane.Client/Modules/Controls/PermissionGrid.razor @@ -111,7 +111,7 @@ [Parameter] public List PermissionList { get; set; } - protected override async Task OnParametersSetAsync() + protected override async Task OnInitializedAsync() { if (!string.IsNullOrEmpty(Permissions)) { diff --git a/Oqtane.Client/Modules/ModuleBase.cs b/Oqtane.Client/Modules/ModuleBase.cs index 1731e22d..4a794630 100644 --- a/Oqtane.Client/Modules/ModuleBase.cs +++ b/Oqtane.Client/Modules/ModuleBase.cs @@ -134,6 +134,7 @@ namespace Oqtane.Modules // url methods + // navigate url public string NavigateUrl() { return NavigateUrl(PageState.Page.Path); @@ -149,24 +150,65 @@ namespace Oqtane.Modules return NavigateUrl(PageState.Page.Path, refresh); } - public string NavigateUrl(string path, string parameters) + public string NavigateUrl(string path, string querystring) { - return Utilities.NavigateUrl(PageState.Alias.Path, path, parameters); + return Utilities.NavigateUrl(PageState.Alias.Path, path, querystring); + } + + public string NavigateUrl(string path, Dictionary querystring) + { + return NavigateUrl(path, Utilities.CreateQueryString(querystring)); } public string NavigateUrl(string path, bool refresh) { - return Utilities.NavigateUrl(PageState.Alias.Path, path, refresh ? "refresh" : ""); + return NavigateUrl(path, refresh ? "refresh" : ""); } + public string NavigateUrl(int moduleId, string action) + { + return EditUrl(PageState.Page.Path, moduleId, action, ""); + } + + public string NavigateUrl(int moduleId, string action, string querystring) + { + return EditUrl(PageState.Page.Path, moduleId, action, querystring); + } + + public string NavigateUrl(int moduleId, string action, Dictionary querystring) + { + return EditUrl(PageState.Page.Path, moduleId, action, querystring); + } + + public string NavigateUrl(string path, int moduleId, string action) + { + return EditUrl(path, moduleId, action, ""); + } + + public string NavigateUrl(string path, int moduleId, string action, string querystring) + { + return EditUrl(path, moduleId, action, querystring); + } + + public string NavigateUrl(string path, int moduleId, string action, Dictionary querystring) + { + return EditUrl(path, moduleId, action, querystring); + } + + // edit url public string EditUrl(string action) { return EditUrl(ModuleState.ModuleId, action); } - public string EditUrl(string action, string parameters) + public string EditUrl(string action, string querystring) { - return EditUrl(ModuleState.ModuleId, action, parameters); + return EditUrl(ModuleState.ModuleId, action, querystring); + } + + public string EditUrl(string action, Dictionary querystring) + { + return EditUrl(ModuleState.ModuleId, action, querystring); } public string EditUrl(int moduleId, string action) @@ -174,16 +216,27 @@ namespace Oqtane.Modules return EditUrl(moduleId, action, ""); } - public string EditUrl(int moduleId, string action, string parameters) + public string EditUrl(int moduleId, string action, string querystring) { - return EditUrl(PageState.Page.Path, moduleId, action, parameters); + return EditUrl(PageState.Page.Path, moduleId, action, querystring); } - public string EditUrl(string path, int moduleid, string action, string parameters) + public string EditUrl(int moduleId, string action, Dictionary querystring) { - return Utilities.EditUrl(PageState.Alias.Path, path, moduleid, action, parameters); + return EditUrl(PageState.Page.Path, moduleId, action, querystring); } + public string EditUrl(string path, int moduleid, string action, string querystring) + { + return Utilities.EditUrl(PageState.Alias.Path, path, moduleid, action, querystring); + } + + public string EditUrl(string path, int moduleid, string action, Dictionary querystring) + { + return EditUrl(path, moduleid, action, Utilities.CreateQueryString(querystring)); + } + + // file url public string FileUrl(string folderpath, string filename) { return FileUrl(folderpath, filename, false); @@ -203,6 +256,8 @@ namespace Oqtane.Modules return Utilities.FileUrl(PageState.Alias, fileid, download); } + // image url + public string ImageUrl(int fileid, int width, int height) { return ImageUrl(fileid, width, height, ""); diff --git a/Oqtane.Client/Oqtane.Client.csproj b/Oqtane.Client/Oqtane.Client.csproj index fd9c693b..b542bf7a 100644 --- a/Oqtane.Client/Oqtane.Client.csproj +++ b/Oqtane.Client/Oqtane.Client.csproj @@ -4,7 +4,7 @@ net8.0 Exe Debug;Release - 5.2.1 + 5.2.2 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.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 https://github.com/oqtane/oqtane.framework Git Oqtane 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/Add.resx b/Oqtane.Client/Resources/Modules/Admin/Users/Add.resx index 1ad645d9..b25b2477 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Users/Add.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Users/Add.resx @@ -117,12 +117,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - Error Adding User. Please Ensure Password Meets Complexity Requirements And Username And Email Is Not Already In Use. - - - Passwords Entered Do Not Match - Error Adding User @@ -133,17 +127,11 @@ Identity - You Must Provide A Username, Password, Email Address And All Required Profile Information + You Must Provide A Username, Email Address And All Required Profile Information Username Already Exists - - Please enter the password again to confirm it matches with the value above - - - Confirm Password: - The full name of the user @@ -156,21 +144,12 @@ Email: - - The user's password. Please choose a password which is sufficiently secure. - - - Password: - A unique username for a user. Note that this field can not be modified once it is saved. Username: - - Password - Indicate if new users should receive an email notification diff --git a/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx index 6baae9c1..46c19999 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'. diff --git a/Oqtane.Client/Services/Interfaces/IUserService.cs b/Oqtane.Client/Services/Interfaces/IUserService.cs index c159c4bd..534466a5 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. /// diff --git a/Oqtane.Client/Services/UserService.cs b/Oqtane.Client/Services/UserService.cs index 286fc2d4..2133d2de 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); diff --git a/Oqtane.Client/Themes/BlazorTheme/Themes/Default.razor b/Oqtane.Client/Themes/BlazorTheme/Themes/Default.razor index 8d682ac9..2682018f 100644 --- a/Oqtane.Client/Themes/BlazorTheme/Themes/Default.razor +++ b/Oqtane.Client/Themes/BlazorTheme/Themes/Default.razor @@ -9,7 +9,7 @@
- +
@@ -41,9 +41,7 @@ Integrity = "sha512-jnSuA4Ss2PkkikSOLtYs8BlYIeeIK1h99ty4YfvRPAlzr377vr3CXDb7sb7eEEBYjDtcYj+AjBH3FLv5uSJuXg==", CrossOrigin = "anonymous" }, new Resource { ResourceType = ResourceType.Stylesheet, Url = ThemePath() + "Theme.css" }, - new Resource { ResourceType = ResourceType.Script, Url = "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js", - Integrity = "sha512-7Pi/otdlbbCR+LnW+F7PwFcSDJOuUJB3OxtEHbg4vSMvzvJjde4Po1v4BR9Gdc9aXNUNFVUY+SK51wWT8WF0Gg==", - CrossOrigin = "anonymous", Location = ResourceLocation.Body }, + new Resource { ResourceType = ResourceType.Script, Url = Constants.BootstrapScriptUrl, Integrity = Constants.BootstrapScriptIntegrity, CrossOrigin = "anonymous", Location = ResourceLocation.Body }, }; } 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 730e7dd8..524f264b 100644 --- a/Oqtane.Client/Themes/Controls/Theme/ControlPanelInteractive.razor +++ b/Oqtane.Client/Themes/Controls/Theme/ControlPanelInteractive.razor @@ -331,7 +331,7 @@ if (_pageId != "-") { _modules = await ModuleService.GetModulesAsync(PageState.Page.SiteId); - _modules = _modules.Where(module => module.PageId == int.Parse(_pageId) && + _modules = _modules.Where(module => module.PageId == int.Parse(_pageId) && module.IsDeleted == false && UserSecurity.IsAuthorized(PageState.User, PermissionNames.View, module.PermissionList) && (_moduleType == "add" || module.ModuleDefinition.IsPortable)) .ToList(); 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 0d8ebb51..19eec4cc 100644 --- a/Oqtane.Client/Themes/Controls/Theme/Search.razor +++ b/Oqtane.Client/Themes/Controls/Theme/Search.razor @@ -1,20 +1,23 @@ @namespace Oqtane.Themes.Controls @using System.Net -@using Microsoft.AspNetCore.Http @inherits ThemeControlBase +@inject ISettingService SettingService @inject IStringLocalizer Localizer @inject NavigationManager NavigationManager @if (_searchResultsPage != null) { - +
- + @if (AllowTextInput) + { + + } @@ -22,9 +25,8 @@ } - - @code { + private string _defaultCssClass; private Page _searchResultsPage; private string _keywords = ""; @@ -32,19 +34,23 @@ public string CssClass { get; set; } [Parameter] - public string SearchResultPagePath { get; set; } = "search"; + public bool AllowTextInput { get; set; } = true; // setting to false will display only the search icon button - not the textbox - [CascadingParameter] - HttpContext HttpContext { get; set; } + [Parameter] + public string SearchResultPagePath { get; set; } = "search"; // setting to "" will disable search [SupplyParameterFromForm(FormName = "SearchForm")] public string KeyWords { get => ""; set => _keywords = value; } protected override void OnInitialized() { - if(!string.IsNullOrEmpty(SearchResultPagePath)) + if (bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "Search_Enabled", "True"))) { - _searchResultsPage = PageState.Pages.FirstOrDefault(i => i.Path == SearchResultPagePath); + _defaultCssClass = (AllowTextInput) ? "app-search" : "app-search-noinput"; + if (!string.IsNullOrEmpty(SearchResultPagePath)) + { + _searchResultsPage = PageState.Pages.FirstOrDefault(i => i.Path == SearchResultPagePath); + } } } 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/ThemeInfo.cs b/Oqtane.Client/Themes/OqtaneTheme/ThemeInfo.cs index a533d45d..bb528190 100644 --- a/Oqtane.Client/Themes/OqtaneTheme/ThemeInfo.cs +++ b/Oqtane.Client/Themes/OqtaneTheme/ThemeInfo.cs @@ -21,9 +21,7 @@ namespace Oqtane.Themes.OqtaneTheme Integrity = "sha512-M+Wrv9LTvQe81gFD2ZE3xxPTN5V2n1iLCXsldIxXvfs6tP+6VihBCwCMBkkjkQUZVmEHBsowb9Vqsq1et1teEg==", CrossOrigin = "anonymous" }, new Resource { ResourceType = ResourceType.Stylesheet, Url = "~/Theme.css" }, - new Resource { ResourceType = ResourceType.Script, Url = "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js", - Integrity = "sha512-7Pi/otdlbbCR+LnW+F7PwFcSDJOuUJB3OxtEHbg4vSMvzvJjde4Po1v4BR9Gdc9aXNUNFVUY+SK51wWT8WF0Gg==", - CrossOrigin = "anonymous", Location = ResourceLocation.Body }, + new Resource { ResourceType = ResourceType.Script, Url = Constants.BootstrapScriptUrl, Integrity = Constants.BootstrapScriptIntegrity, CrossOrigin = "anonymous", Location = ResourceLocation.Body } } }; } 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/Themes/ThemeBase.cs b/Oqtane.Client/Themes/ThemeBase.cs index 68da1a70..d6f9789e 100644 --- a/Oqtane.Client/Themes/ThemeBase.cs +++ b/Oqtane.Client/Themes/ThemeBase.cs @@ -93,6 +93,7 @@ namespace Oqtane.Themes // url methods + // navigate url public string NavigateUrl() { return NavigateUrl(PageState.Page.Path); @@ -108,31 +109,78 @@ namespace Oqtane.Themes return NavigateUrl(PageState.Page.Path, refresh); } + public string NavigateUrl(string path, string querystring) + { + return Utilities.NavigateUrl(PageState.Alias.Path, path, querystring); + } + + public string NavigateUrl(string path, Dictionary querystring) + { + return NavigateUrl(path, Utilities.CreateQueryString(querystring)); + } + public string NavigateUrl(string path, bool refresh) { - return Utilities.NavigateUrl(PageState.Alias.Path, path, refresh ? "refresh" : ""); + return NavigateUrl(path, refresh ? "refresh" : ""); } - public string NavigateUrl(string path, string parameters) + public string NavigateUrl(int moduleid, string action) { - return Utilities.NavigateUrl(PageState.Alias.Path, path, parameters); + return EditUrl(moduleid, action, ""); } + public string NavigateUrl(int moduleid, string action, string querystring) + { + return EditUrl(PageState.Page.Path, moduleid, action, querystring); + } + + public string NavigateUrl(int moduleid, string action, Dictionary querystring) + { + return EditUrl(PageState.Page.Path, moduleid, action, Utilities.CreateQueryString(querystring)); + } + + public string NavigateUrl(string path, int moduleId, string action) + { + return EditUrl(path, moduleId, action, ""); + } + + public string NavigateUrl(string path, int moduleid, string action, string querystring) + { + return EditUrl(path, moduleid, action, querystring); + } + + public string NavigateUrl(string path, int moduleid, string action, Dictionary querystring) + { + return EditUrl(path, moduleid, action, querystring); + } + + // edit url public string EditUrl(int moduleid, string action) { return EditUrl(moduleid, action, ""); } - public string EditUrl(int moduleid, string action, string parameters) + public string EditUrl(int moduleid, string action, string querystring) { - return EditUrl(PageState.Page.Path, moduleid, action, parameters); + return EditUrl(PageState.Page.Path, moduleid, action, querystring); } - public string EditUrl(string path, int moduleid, string action, string parameters) + public string EditUrl(int moduleid, string action, Dictionary querystring) { - return Utilities.EditUrl(PageState.Alias.Path, path, moduleid, action, parameters); + return EditUrl(PageState.Page.Path, moduleid, action, querystring); } + public string EditUrl(string path, int moduleid, string action, string querystring) + { + return Utilities.EditUrl(PageState.Alias.Path, path, moduleid, action, querystring); + } + + public string EditUrl(string path, int moduleid, string action, Dictionary querystring) + { + return EditUrl(path, moduleid, action, Utilities.CreateQueryString(querystring)); + } + + // file url public string FileUrl(string folderpath, string filename) { return FileUrl(folderpath, filename, false); @@ -152,6 +200,7 @@ namespace Oqtane.Themes return Utilities.FileUrl(PageState.Alias, fileid, download); } + // image url public string ImageUrl(int fileid, int width, int height) { return ImageUrl(fileid, width, height, ""); diff --git a/Oqtane.Client/UI/Routes.razor b/Oqtane.Client/UI/Routes.razor index fb88abcb..de6503f2 100644 --- a/Oqtane.Client/UI/Routes.razor +++ b/Oqtane.Client/UI/Routes.razor @@ -61,7 +61,6 @@ { SiteState.AntiForgeryToken = AntiForgeryToken; SiteState.AuthorizationToken = AuthorizationToken; - SiteState.RemoteIPAddress = (_pageState != null) ? _pageState.RemoteIPAddress : ""; SiteState.Platform = Platform; SiteState.IsPrerendering = (HttpContext != null) ? true : false; @@ -80,6 +79,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..0d5ebc1b 100644 --- a/Oqtane.Client/UI/SiteRouter.razor +++ b/Oqtane.Client/UI/SiteRouter.razor @@ -157,7 +157,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 +287,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 +366,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 +403,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 +436,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 +504,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.0 + 5.2.2 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.0 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 https://github.com/oqtane/oqtane.framework Git true diff --git a/Oqtane.Database.PostgreSQL/Oqtane.Database.PostgreSQL.csproj b/Oqtane.Database.PostgreSQL/Oqtane.Database.PostgreSQL.csproj index 980e7582..18619910 100644 --- a/Oqtane.Database.PostgreSQL/Oqtane.Database.PostgreSQL.csproj +++ b/Oqtane.Database.PostgreSQL/Oqtane.Database.PostgreSQL.csproj @@ -2,7 +2,7 @@ net8.0 - 5.2.1 + 5.2.2 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.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 https://github.com/oqtane/oqtane.framework Git true diff --git a/Oqtane.Database.SqlServer/Oqtane.Database.SqlServer.csproj b/Oqtane.Database.SqlServer/Oqtane.Database.SqlServer.csproj index 835bcee7..7fdfae00 100644 --- a/Oqtane.Database.SqlServer/Oqtane.Database.SqlServer.csproj +++ b/Oqtane.Database.SqlServer/Oqtane.Database.SqlServer.csproj @@ -2,7 +2,7 @@ net8.0 - 5.2.1 + 5.2.2 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.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 https://github.com/oqtane/oqtane.framework Git true diff --git a/Oqtane.Database.Sqlite/Oqtane.Database.Sqlite.csproj b/Oqtane.Database.Sqlite/Oqtane.Database.Sqlite.csproj index 6caeb51a..1fe344e3 100644 --- a/Oqtane.Database.Sqlite/Oqtane.Database.Sqlite.csproj +++ b/Oqtane.Database.Sqlite/Oqtane.Database.Sqlite.csproj @@ -2,7 +2,7 @@ net8.0 - 5.2.1 + 5.2.2 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.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 https://github.com/oqtane/oqtane.framework Git true diff --git a/Oqtane.Maui/Oqtane.Maui.csproj b/Oqtane.Maui/Oqtane.Maui.csproj index 571517dd..f6b66171 100644 --- a/Oqtane.Maui/Oqtane.Maui.csproj +++ b/Oqtane.Maui/Oqtane.Maui.csproj @@ -6,7 +6,7 @@ Exe - 5.2.1 + 5.2.2 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.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 https://github.com/oqtane/oqtane.framework Git Oqtane.Maui @@ -31,7 +31,7 @@ 0E29FC31-1B83-48ED-B6E0-9F3C67B775D4 - 5.2.1 + 5.2.2 1 14.2 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..9bc74bb8 100644 --- a/Oqtane.Maui/wwwroot/js/interop.js +++ b/Oqtane.Maui/wwwroot/js/interop.js @@ -198,7 +198,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 +208,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 +295,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 +345,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); diff --git a/Oqtane.Package/Oqtane.Client.nuspec b/Oqtane.Package/Oqtane.Client.nuspec index e0aa2ac0..88edf912 100644 --- a/Oqtane.Package/Oqtane.Client.nuspec +++ b/Oqtane.Package/Oqtane.Client.nuspec @@ -1,8 +1,8 @@ - + Oqtane.Client - 5.2.1 + 5.2.2 Shaun Walker .NET Foundation Oqtane Framework @@ -12,7 +12,8 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + readme.md icon.png oqtane @@ -20,5 +21,6 @@ + diff --git a/Oqtane.Package/Oqtane.Framework.nuspec b/Oqtane.Package/Oqtane.Framework.nuspec index 888d4e23..2d50ae8b 100644 --- a/Oqtane.Package/Oqtane.Framework.nuspec +++ b/Oqtane.Package/Oqtane.Framework.nuspec @@ -1,8 +1,8 @@  - + Oqtane.Framework - 5.2.1 + 5.2.2 Shaun Walker .NET Foundation Oqtane Framework @@ -11,12 +11,14 @@ .NET Foundation false MIT - https://github.com/oqtane/oqtane.framework/releases/download/v5.2.1/Oqtane.Framework.5.2.1.Upgrade.zip - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.1 + 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 + readme.md icon.png oqtane framework + diff --git a/Oqtane.Package/Oqtane.Server.nuspec b/Oqtane.Package/Oqtane.Server.nuspec index b35f66f3..85b07175 100644 --- a/Oqtane.Package/Oqtane.Server.nuspec +++ b/Oqtane.Package/Oqtane.Server.nuspec @@ -1,8 +1,8 @@ - + Oqtane.Server - 5.2.1 + 5.2.2 Shaun Walker .NET Foundation Oqtane Framework @@ -12,7 +12,8 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + readme.md icon.png oqtane @@ -20,5 +21,6 @@ + diff --git a/Oqtane.Package/Oqtane.Shared.nuspec b/Oqtane.Package/Oqtane.Shared.nuspec index 334af891..f5a867cf 100644 --- a/Oqtane.Package/Oqtane.Shared.nuspec +++ b/Oqtane.Package/Oqtane.Shared.nuspec @@ -1,8 +1,8 @@ - + Oqtane.Shared - 5.2.1 + 5.2.2 Shaun Walker .NET Foundation Oqtane Framework @@ -12,7 +12,8 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + readme.md icon.png oqtane @@ -20,5 +21,6 @@ + diff --git a/Oqtane.Package/Oqtane.Updater.nuspec b/Oqtane.Package/Oqtane.Updater.nuspec index 208c14ef..858c54f5 100644 --- a/Oqtane.Package/Oqtane.Updater.nuspec +++ b/Oqtane.Package/Oqtane.Updater.nuspec @@ -1,8 +1,8 @@  - + Oqtane.Updater - 5.2.1 + 5.2.2 Shaun Walker .NET Foundation Oqtane Framework @@ -12,12 +12,14 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 + readme.md icon.png oqtane + diff --git a/Oqtane.Package/install.ps1 b/Oqtane.Package/install.ps1 index 4db2aa1e..b6230b38 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.1.Install.zip" -Force +Compress-Archive -Path "..\Oqtane.Server\bin\Release\net8.0\publish\*" -DestinationPath "Oqtane.Framework.5.2.2.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/readme.md b/Oqtane.Package/readme.md new file mode 100644 index 00000000..189ef49a --- /dev/null +++ b/Oqtane.Package/readme.md @@ -0,0 +1,9 @@ +# Oqtane Framework + +![Oqtane](https://github.com/oqtane/framework/blob/master/oqtane.png?raw=true "Oqtane") + +Oqtane is an open source CMS and Application Framework that provides advanced functionality for developing web, mobile, and desktop applications on .NET. It leverages Blazor to compose a fully dynamic digital experience which can be hosted on Static Blazor, Blazor Server, Blazor WebAssembly, or Blazor Hybrid (via .NET MAUI). + +Oqtane was created by [Shaun Walker](https://www.linkedin.com/in/shaunbrucewalker/) and was inspired by his earlier efforts with DotNetNuke... however Oqtane is a native Blazor application written from the ground up using modern .NET Core technology and a Single Page Application (SPA) architecture. It is a modular application framework offering a fully dynamic page compositing model, multi-site support, designer friendly themes, and extensibility via third party modules. + +More information about Oqtane can be found at: [https://www.oqtane.org](https://www.oqtane.org) diff --git a/Oqtane.Package/upgrade.ps1 b/Oqtane.Package/upgrade.ps1 index 5c8713b6..b2f11245 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.1.Upgrade.zip" -Force +Compress-Archive -Path "..\Oqtane.Server\bin\Release\net8.0\publish\*" -DestinationPath "Oqtane.Framework.5.2.2.Upgrade.zip" -Force diff --git a/Oqtane.Server/Components/App.razor b/Oqtane.Server/Components/App.razor index 2277adea..82a5d450 100644 --- a/Oqtane.Server/Components/App.razor +++ b/Oqtane.Server/Components/App.razor @@ -534,9 +534,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) @@ -644,6 +644,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 +696,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..5aa8a66e 100644 --- a/Oqtane.Server/Controllers/FileController.cs +++ b/Oqtane.Server/Controllers/FileController.cs @@ -425,11 +425,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 +437,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 +492,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) diff --git a/Oqtane.Server/Controllers/UserController.cs b/Oqtane.Server/Controllers/UserController.cs index 549e00e3..9e80be8d 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; @@ -28,9 +27,10 @@ namespace Oqtane.Controllers private readonly IUserPermissions _userPermissions; private readonly IJwtManager _jwtManager; private readonly IFileRepository _files; + private readonly ISettingRepository _settings; private readonly ILogManager _logger; - public UserController(IUserRepository users, ITenantManager tenantManager, IUserManager userManager, ISiteRepository sites, IUserPermissions userPermissions, IJwtManager jwtManager, IFileRepository files, ILogManager logger) + public UserController(IUserRepository users, ITenantManager tenantManager, IUserManager userManager, ISiteRepository sites, IUserPermissions userPermissions, IJwtManager jwtManager, IFileRepository files, ISettingRepository settings, ILogManager logger) { _users = users; _tenantManager = tenantManager; @@ -39,6 +39,7 @@ namespace Oqtane.Controllers _userPermissions = userPermissions; _jwtManager = jwtManager; _files = files; + _settings = settings; _logger = logger; } @@ -110,31 +111,58 @@ namespace Oqtane.Controllers private User Filter(User user) { + // clone object to avoid mutating cache + User filtered = null; + if (user != null) { - user.Password = ""; - user.IsAuthenticated = false; - user.TwoFactorCode = ""; - user.TwoFactorExpiry = null; + filtered = new User(); - if (!_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) && User.Identity.Name?.ToLower() != user.Username.ToLower()) + // 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) { - user.Email = ""; - user.PhotoFileId = null; - user.LastLoginOn = DateTime.MinValue; - user.LastIPAddress = ""; - user.Roles = ""; - user.CreatedBy = ""; - user.CreatedOn = DateTime.MinValue; - user.ModifiedBy = ""; - user.ModifiedOn = DateTime.MinValue; - user.DeletedBy = ""; - user.DeletedOn = DateTime.MinValue; - user.IsDeleted = false; - user.TwoFactorRequired = false; + filtered.Email = user.Email; + filtered.PhotoFileId = user.PhotoFileId; + filtered.LastLoginOn = user.LastLoginOn; + filtered.LastIPAddress = user.LastIPAddress; + filtered.TwoFactorRequired = false; + filtered.Roles = user.Roles; + filtered.CreatedBy = user.CreatedBy; + filtered.CreatedOn = user.CreatedOn; + filtered.ModifiedBy = user.ModifiedBy; + filtered.ModifiedOn = user.ModifiedOn; + filtered.DeletedBy = user.DeletedBy; + filtered.DeletedOn = user.DeletedOn; + filtered.IsDeleted = user.IsDeleted; + } + + // if authenticated user is accessing their own user account + if (_userPermissions.GetUser(User).UserId == user.UserId) + { + // include all settings + filtered.Settings = user.Settings; + } + else + { + // include only public settings + filtered.Settings = _settings.GetSettings(EntityNames.User, user.UserId) + .Where(item => !item.IsPrivate) + .ToDictionary(setting => setting.SettingName, setting => setting.SettingValue); } } - return user; + + return filtered; } // POST api/ @@ -147,11 +175,13 @@ namespace Oqtane.Controllers if (_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin)) { user.EmailConfirmed = true; + user.IsAuthenticated = true; allowregistration = true; } else { user.EmailConfirmed = false; + user.IsAuthenticated = false; allowregistration = _sites.GetSite(user.SiteId).AllowRegistration; } @@ -233,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 @@ -355,6 +401,7 @@ namespace Oqtane.Controllers } if (roles != "") roles = ";" + roles; user.Roles = roles; + user.SecurityStamp = User.SecurityStamp(); } return user; } 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/OqtaneSiteAuthenticationBuilderExtensions.cs b/Oqtane.Server/Extensions/OqtaneSiteAuthenticationBuilderExtensions.cs index 604270c1..1729358b 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.FindByEmailAsync(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,7 +645,7 @@ 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, providerName); } } else // claims invalid 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..5ada9827 100644 --- a/Oqtane.Server/Managers/Interfaces/IUserManager.cs +++ b/Oqtane.Server/Managers/Interfaces/IUserManager.cs @@ -13,6 +13,7 @@ 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); diff --git a/Oqtane.Server/Managers/UserManager.cs b/Oqtane.Server/Managers/UserManager.cs index 5f234c0e..e0e92e97 100644 --- a/Oqtane.Server/Managers/UserManager.cs +++ b/Oqtane.Server/Managers/UserManager.cs @@ -64,8 +64,8 @@ namespace Oqtane.Managers { user.SiteId = siteid; user.Roles = GetUserRoles(user.UserId, user.SiteId); - List settings = _settings.GetSettings(EntityNames.User, user.UserId).ToList(); - user.Settings = settings.Where(item => !item.IsPrivate || user.UserId == user.UserId) + user.SecurityStamp = _identityUserManager.FindByNameAsync(user.Username).GetAwaiter().GetResult()?.SecurityStamp; + user.Settings = _settings.GetSettings(EntityNames.User, user.UserId) .ToDictionary(setting => setting.SettingName, setting => setting.SettingValue); } return user; @@ -145,13 +145,17 @@ namespace Oqtane.Managers } else { - var result = await _identitySignInManager.CheckPasswordSignInAsync(identityuser, user.Password, false); - succeeded = result.Succeeded; - if (!succeeded) + succeeded = true; + if (!user.IsAuthenticated) { - errors = "Password Not Valid For User"; + var result = await _identitySignInManager.CheckPasswordSignInAsync(identityuser, user.Password, false); + succeeded = result.Succeeded; + if (!succeeded) + { + errors = "Password Not Valid For User"; + } + user.EmailConfirmed = succeeded; } - user.EmailConfirmed = succeeded; } if (succeeded) @@ -227,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 { @@ -237,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) @@ -259,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); } @@ -367,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) { @@ -414,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) { @@ -469,6 +484,7 @@ namespace Oqtane.Managers IdentityUser identityuser = await _identityUserManager.FindByNameAsync(user.Username); if (identityuser != null && !string.IsNullOrEmpty(token)) { + // note that ResetPasswordAsync checks password complexity rules var result = await _identityUserManager.ResetPasswordAsync(identityuser, token, user.Password); if (result.Succeeded) { diff --git a/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs b/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs index b97a3d60..8f2cb20a 100644 --- a/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs +++ b/Oqtane.Server/Migrations/EntityBuilders/BaseEntityBuilder.cs @@ -127,6 +127,46 @@ namespace Oqtane.Migrations.EntityBuilders return table.Column(name: RewriteName(name), nullable: nullable, defaultValue: defaultValue); } + public void AddDateOnlyColumn(string name, bool nullable = false) + { + _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, schema: Schema); + } + + public void AddDateOnlyColumn(string name, bool nullable, DateOnly defaultValue) + { + _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, defaultValue: defaultValue, schema: Schema); + } + + protected OperationBuilder AddDateOnlyColumn(ColumnsBuilder table, string name, bool nullable = false) + { + return table.Column(name: RewriteName(name), nullable: nullable); + } + + protected OperationBuilder AddDateOnlyColumn(ColumnsBuilder table, string name, bool nullable, DateOnly defaultValue) + { + return table.Column(name: RewriteName(name), nullable: nullable, defaultValue: defaultValue); + } + + public void AddTimeOnlyColumn(string name, bool nullable = false) + { + _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, schema: Schema); + } + + public void AddTimeOnlyColumn(string name, bool nullable, TimeOnly defaultValue) + { + _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, defaultValue: defaultValue, schema: Schema); + } + + protected OperationBuilder AddTimeOnlyColumn(ColumnsBuilder table, string name, bool nullable = false) + { + return table.Column(name: RewriteName(name), nullable: nullable); + } + + protected OperationBuilder AddTimeOnlyColumn(ColumnsBuilder table, string name, bool nullable, TimeOnly defaultValue) + { + return table.Column(name: RewriteName(name), nullable: nullable, defaultValue: defaultValue); + } + public void AddByteColumn(string name, bool nullable = false) { _migrationBuilder.AddColumn(RewriteName(name), RewriteName(EntityTableName), nullable: nullable, schema: Schema); diff --git a/Oqtane.Server/Oqtane.Server.csproj b/Oqtane.Server/Oqtane.Server.csproj index fd2b6731..12a52f9b 100644 --- a/Oqtane.Server/Oqtane.Server.csproj +++ b/Oqtane.Server/Oqtane.Server.csproj @@ -3,7 +3,7 @@ net8.0 Debug;Release - 5.2.1 + 5.2.2 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.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 https://github.com/oqtane/oqtane.framework Git Oqtane 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/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/LogRepository.cs b/Oqtane.Server/Repository/LogRepository.cs index 313c9881..12f43a21 100644 --- a/Oqtane.Server/Repository/LogRepository.cs +++ b/Oqtane.Server/Repository/LogRepository.cs @@ -48,6 +48,12 @@ namespace Oqtane.Repository public void AddLog(Log log) { + if (log.Url.Length > 2048) log.Url = log.Url.Substring(0, 2048); + if (log.Server.Length > 200) log.Server = log.Server.Substring(0, 200); + if (log.Category.Length > 200) log.Category = log.Category.Substring(0, 200); + if (log.Feature.Length > 200) log.Feature = log.Feature.Substring(0, 200); + if (log.Function.Length > 20) log.Function = log.Function.Substring(0, 20); + if (log.Level.Length > 20) log.Level = log.Level.Substring(0, 20); using var db = _dbContextFactory.CreateDbContext(); db.Log.Add(log); db.SaveChanges(); 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/UserRoleRepository.cs b/Oqtane.Server/Repository/UserRoleRepository.cs index 0a4a04eb..c438bdb4 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,10 +72,8 @@ namespace Oqtane.Repository DeleteUserRoles(userRole.UserId); } - var alias = _tenantManager.GetAlias(); - _cache.Remove($"user:{userRole.UserId}:{alias.SiteKey}"); - _cache.Remove($"userroles:{userRole.UserId}:{alias.SiteKey}"); - + UpdateSecurityStamp(userRole.UserId); + return userRole; } @@ -82,9 +83,7 @@ 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}"); + UpdateSecurityStamp(userRole.UserId); return userRole; } @@ -144,9 +143,7 @@ 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); } public void DeleteUserRoles(int userId) @@ -158,9 +155,30 @@ namespace Oqtane.Repository } db.SaveChanges(); + UpdateSecurityStamp(userId); + } + + private void UpdateSecurityStamp(int userId) + { + // update user security stamp + 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); + } + } + + // refresh cache 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..dc6d7256 100644 --- a/Oqtane.Server/Security/PrincipalValidator.cs +++ b/Oqtane.Server/Security/PrincipalValidator.cs @@ -3,12 +3,11 @@ 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; + namespace Oqtane.Security { @@ -24,49 +23,38 @@ 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(); + } } 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); } } } @@ -77,7 +65,8 @@ namespace Oqtane.Security { 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/SiteService.cs b/Oqtane.Server/Services/SiteService.cs index 595c4ddd..08e64147 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,18 +70,26 @@ 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; return Task.FromResult(site); @@ -94,14 +103,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,14 +117,13 @@ 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()); @@ -249,31 +256,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 +315,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/wwwroot/Modules/Templates/External/Client/Startup/ClientStartup.cs b/Oqtane.Server/wwwroot/Modules/Templates/External/Client/Startup/ClientStartup.cs new file mode 100644 index 00000000..611b5a8e --- /dev/null +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Client/Startup/ClientStartup.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; +using Oqtane.Services; +using [Owner].Module.[Module].Services; + +namespace [Owner].Module.[Module].Startup +{ + public class ClientStartup : IClientStartup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + } + } +} diff --git a/Oqtane.Server/wwwroot/Modules/Templates/External/Server/Startup/ServerStartup.cs b/Oqtane.Server/wwwroot/Modules/Templates/External/Server/Startup/ServerStartup.cs new file mode 100644 index 00000000..a5491925 --- /dev/null +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Server/Startup/ServerStartup.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Oqtane.Infrastructure; +using [Owner].Module.[Module].Repository; +using [Owner].Module.[Module].Services; + +namespace [Owner].Module.[Module].Startup +{ + public class ServerStartup : IServerStartup + { + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + // not implemented + } + + public void ConfigureMvc(IMvcBuilder mvcBuilder) + { + // not implemented + } + + public void ConfigureServices(IServiceCollection services) + { + services.AddTransient(); + services.AddDbContextFactory<[Module]Context>(opt => { }, ServiceLifetime.Transient); + } + } +} 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/app_offline.bak b/Oqtane.Server/wwwroot/app_offline.bak index 6f558c45..4fcd2b88 100644 --- a/Oqtane.Server/wwwroot/app_offline.bak +++ b/Oqtane.Server/wwwroot/app_offline.bak @@ -5,24 +5,30 @@ Upgrade Framework - + + - +


Please Wait... Upgrade In Progress...

(this process can take a few minutes... please be patient)

- -
+
+
+
+
+
+ [STATUS] +
+
- - + \ No newline at end of file diff --git a/Oqtane.Server/wwwroot/css/app.css b/Oqtane.Server/wwwroot/css/app.css index a55abaeb..85350ca6 100644 --- a/Oqtane.Server/wwwroot/css/app.css +++ b/Oqtane.Server/wwwroot/css/app.css @@ -235,23 +235,40 @@ app { .app-form-inline { display: inline; } -.app-search{ + +.app-search { display: inline-block; position: relative; } -.app-search input + button{ +.app-search input + button { background: none; border: none; position: absolute; right: 0; top: 0; } -.app-search input + button .oi{ +.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.Server/wwwroot/js/interop.js b/Oqtane.Server/wwwroot/js/interop.js index b457ad1d..9bc74bb8 100644 --- a/Oqtane.Server/wwwroot/js/interop.js +++ b/Oqtane.Server/wwwroot/js/interop.js @@ -198,7 +198,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 +295,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 +345,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); 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/Language.cs b/Oqtane.Shared/Models/Language.cs index 11f4af39..82a837ce 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 @@ -40,5 +39,18 @@ namespace Oqtane.Models /// 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..c83f1234 100644 --- a/Oqtane.Shared/Models/Permission.cs +++ b/Oqtane.Shared/Models/Permission.cs @@ -101,17 +101,21 @@ 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, + UserId = UserId, + IsAuthorized = IsAuthorized, + CreatedBy = CreatedBy, + CreatedOn = CreatedOn, + ModifiedBy = ModifiedBy, + ModifiedOn = ModifiedOn }; } 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/Oqtane.Shared.csproj b/Oqtane.Shared/Oqtane.Shared.csproj index bf8779d0..9e5ae9d8 100644 --- a/Oqtane.Shared/Oqtane.Shared.csproj +++ b/Oqtane.Shared/Oqtane.Shared.csproj @@ -3,7 +3,7 @@ net8.0 Debug;Release - 5.2.1 + 5.2.2 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.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 https://github.com/oqtane/oqtane.framework Git Oqtane 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 9ab5428b..c8284c18 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.1"; - 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"; + 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 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"; @@ -83,6 +86,11 @@ namespace Oqtane.Shared public static readonly string[] InternalPagePaths = { "login", "register", "reset", "404" }; public const string DefaultTextEditor = "Oqtane.Modules.Controls.QuillJSTextEditor, Oqtane.Client"; + public const string BootstrapScriptUrl = "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"; + public const string BootstrapScriptIntegrity = "sha512-7Pi/otdlbbCR+LnW+F7PwFcSDJOuUJB3OxtEHbg4vSMvzvJjde4Po1v4BR9Gdc9aXNUNFVUY+SK51wWT8WF0Gg=="; + public const string BootstrapStylesheetUrl = "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"; + public const string BootstrapStylesheetIntegrity = "sha512-jnSuA4Ss2PkkikSOLtYs8BlYIeeIK1h99ty4YfvRPAlzr377vr3CXDb7sb7eEEBYjDtcYj+AjBH3FLv5uSJuXg=="; + // Obsolete constants const string RoleObsoleteMessage = "Use the corresponding member from Oqtane.Shared.RoleNames"; diff --git a/Oqtane.Shared/Shared/Utilities.cs b/Oqtane.Shared/Shared/Utilities.cs index 408e889c..6e8f3933 100644 --- a/Oqtane.Shared/Shared/Utilities.cs +++ b/Oqtane.Shared/Shared/Utilities.cs @@ -23,12 +23,13 @@ namespace Oqtane.Shared public static (string UrlParameters, string Querystring, string Fragment) ParseParameters(string parameters) { - // /urlparameters /urlparameters?Id=1 /urlparameters#5 /urlparameters?Id=1#5 /urlparameters?reload#5 - // Id=1 Id=1#5 reload#5 reload + // /urlparameters /urlparameters?id=1 /urlparameters#5 /urlparameters?id=1#5 /urlparameters?reload#5 + // ?id=1 ?id=1#5 ?reload#5 ?reload + // id=1 id=1#5 reload#5 reload // #5 // create absolute url to convert to Uri - parameters = (!parameters.StartsWith("/") && !parameters.StartsWith("#") ? "?" : "") + parameters; + parameters = (!parameters.StartsWith("/") && !parameters.StartsWith("#") && !parameters.StartsWith("?") ? "?" : "") + parameters; parameters = Constants.PackageRegistryUrl + parameters; var uri = new Uri(parameters); var querystring = uri.Query.Replace("?", ""); diff --git a/Oqtane.Updater.sln b/Oqtane.Updater.sln index ee633e20..f79c8fed 100644 --- a/Oqtane.Updater.sln +++ b/Oqtane.Updater.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.28822.285 @@ -12,6 +12,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Oqtane.Updater", "Oqtane.Updater\Oqtane.Updater.csproj", "{2E8C6889-37CF-4C8D-88B1-505547F25098}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Oqtane.Shared", "Oqtane.Shared\Oqtane.Shared.csproj", "{E2512C17-291F-460A-A6D1-741C301DA184}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -22,6 +24,10 @@ Global {2E8C6889-37CF-4C8D-88B1-505547F25098}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E8C6889-37CF-4C8D-88B1-505547F25098}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E8C6889-37CF-4C8D-88B1-505547F25098}.Release|Any CPU.Build.0 = Release|Any CPU + {E2512C17-291F-460A-A6D1-741C301DA184}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2512C17-291F-460A-A6D1-741C301DA184}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2512C17-291F-460A-A6D1-741C301DA184}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2512C17-291F-460A-A6D1-741C301DA184}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Oqtane.Updater/Oqtane.Updater.csproj b/Oqtane.Updater/Oqtane.Updater.csproj index 7144958d..3d1adf6c 100644 --- a/Oqtane.Updater/Oqtane.Updater.csproj +++ b/Oqtane.Updater/Oqtane.Updater.csproj @@ -3,7 +3,7 @@ net8.0 Exe - 5.2.1 + 5.2.2 Oqtane Shaun Walker .NET Foundation @@ -11,11 +11,15 @@ .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.1 + https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.2 https://github.com/oqtane/oqtane.framework Git Oqtane false + + + + diff --git a/Oqtane.Updater/Program.cs b/Oqtane.Updater/Program.cs index 22c0b4d9..cd8f8359 100644 --- a/Oqtane.Updater/Program.cs +++ b/Oqtane.Updater/Program.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Threading; +using Oqtane.Shared; namespace Oqtane.Updater { @@ -31,10 +32,6 @@ namespace Oqtane.Updater if (Directory.Exists(deployfolder)) { - string log = "Upgrade Process Started: " + DateTime.UtcNow.ToString() + Environment.NewLine; - log += "ContentRootPath: " + contentrootfolder + Environment.NewLine; - log += "WebRootPath: " + webrootfolder + Environment.NewLine; - string packagename = ""; string[] packages = Directory.GetFiles(deployfolder, "Oqtane.Framework.*.Upgrade.zip"); if (packages.Length > 0) @@ -42,15 +39,27 @@ namespace Oqtane.Updater packagename = packages[packages.Length - 1]; // use highest version } + // create upgrade log file + var logFilePath = Path.Combine(deployfolder, $"{Path.GetFileNameWithoutExtension(packagename)}.log"); + if (File.Exists(logFilePath)) + { + File.Delete(logFilePath); + } + + WriteLog(logFilePath, "Upgrade Process Started: " + DateTime.UtcNow.ToString() + Environment.NewLine); + WriteLog(logFilePath, "ContentRootPath: " + contentrootfolder + Environment.NewLine); + WriteLog(logFilePath, "WebRootPath: " + webrootfolder + Environment.NewLine); if (packagename != "" && File.Exists(Path.Combine(webrootfolder, "app_offline.bak"))) { - log += "Located Upgrade Package: " + packagename + Environment.NewLine; + WriteLog(logFilePath, "Located Upgrade Package: " + packagename + Environment.NewLine); - log += "Stopping Application Using: " + Path.Combine(contentrootfolder, "app_offline.htm") + Environment.NewLine; - File.Copy(Path.Combine(webrootfolder, "app_offline.bak"), Path.Combine(contentrootfolder, "app_offline.htm"), true); + WriteLog(logFilePath, "Stopping Application Using: " + Path.Combine(contentrootfolder, "app_offline.htm") + Environment.NewLine); + var offlineTemplate = File.ReadAllText(Path.Combine(webrootfolder, "app_offline.bak")); + var offlineFilePath = Path.Combine(contentrootfolder, "app_offline.htm"); // get list of files in package with local paths - log += "Retrieving List Of Files From Upgrade Package..." + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 5, "Retrieving List Of Files From Upgrade Package"); + WriteLog(logFilePath, "Retrieving List Of Files From Upgrade Package..." + Environment.NewLine); List files = new List(); using (ZipArchive archive = ZipFile.OpenRead(packagename)) { @@ -59,15 +68,18 @@ namespace Oqtane.Updater if (!string.IsNullOrEmpty(entry.Name)) { files.Add(Path.Combine(contentrootfolder, entry.FullName)); + WriteLog(logFilePath, "Check File: " + entry.FullName + Environment.NewLine); } } } + bool success = true; // ensure files are not locked if (CanAccessFiles(files)) { - log += "Preparing Backup Folder: " + backupfolder + Environment.NewLine; - bool success = true; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 10, "Preparing Backup Folder"); + WriteLog(logFilePath, "Preparing Backup Folder: " + backupfolder + Environment.NewLine); + try { // clear out backup folder @@ -79,14 +91,16 @@ namespace Oqtane.Updater } catch (Exception ex) { - log += "Error Creating Backup Folder: " + ex.Message + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 95, "Error Creating Backup Folder", "bg-danger"); + WriteLog(logFilePath, "Error Creating Backup Folder: " + ex.Message + Environment.NewLine); success = false; } // backup files if (success) { - log += "Backing Up Files..." + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 15, "Backing Up Files"); + WriteLog(logFilePath, "Backing Up Files..." + Environment.NewLine); foreach (string file in files) { string filename = Path.Combine(backupfolder, file.Replace(contentrootfolder + Path.DirectorySeparatorChar, "")); @@ -99,12 +113,15 @@ namespace Oqtane.Updater Directory.CreateDirectory(Path.GetDirectoryName(filename)); } File.Copy(file, filename); + WriteLog(logFilePath, "Copy File: " + filename + Environment.NewLine); } } catch (Exception ex) { - log += "Error Backing Up Files: " + ex.Message + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 95, "Error Backing Up Files", "bg-danger"); + WriteLog(logFilePath, "Error Backing Up Files: " + ex.Message + Environment.NewLine); success = false; + break; } } } @@ -112,7 +129,8 @@ namespace Oqtane.Updater // extract files if (success) { - log += "Extracting Files From Upgrade Package..." + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 50, "Extracting Files From Upgrade Package"); + WriteLog(logFilePath, "Extracting Files From Upgrade Package..." + Environment.NewLine); try { using (ZipArchive archive = ZipFile.OpenRead(packagename)) @@ -127,6 +145,7 @@ namespace Oqtane.Updater Directory.CreateDirectory(Path.GetDirectoryName(filename)); } entry.ExtractToFile(filename, true); + WriteLog(logFilePath, "Exact File: " + filename + Environment.NewLine); } } } @@ -134,12 +153,14 @@ namespace Oqtane.Updater catch (Exception ex) { success = false; - log += "Error Extracting Files From Upgrade Package: " + ex.Message + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 95, "Error Extracting Files From Upgrade Package", "bg-danger"); + WriteLog(logFilePath, "Error Extracting Files From Upgrade Package: " + ex.Message + Environment.NewLine); } if (success) { - log += "Removing Backup Folder..." + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 90, "Removing Backup Folder"); + WriteLog(logFilePath, "Removing Backup Folder..." + Environment.NewLine); try { // clean up backup @@ -149,12 +170,14 @@ namespace Oqtane.Updater } catch (Exception ex) { - log += "Error Removing Backup Folder: " + ex.Message + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 95, "Error Extracting Files From Upgrade Package", "bg-warning"); + WriteLog(logFilePath, "Error Removing Backup Folder: " + ex.Message + Environment.NewLine); } } else { - log += "Restoring Files From Backup Folder..." + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 50, "Upgrade Failed, Restoring Files From Backup Folder", "bg-warning"); + WriteLog(logFilePath, "Restoring Files From Backup Folder..." + Environment.NewLine); try { // restore on failure @@ -165,6 +188,7 @@ namespace Oqtane.Updater if (File.Exists(filename)) { File.Copy(filename, file); + WriteLog(logFilePath, "Restore File: " + filename + Environment.NewLine); } } // clean up backup @@ -172,41 +196,38 @@ namespace Oqtane.Updater } catch (Exception ex) { - log += "Error Restoring Files From Backup Folder: " + ex.Message + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 95, "Error Restoring Files From Backup Folder", "bg-danger"); + WriteLog(logFilePath, "Error Restoring Files From Backup Folder: " + ex.Message + Environment.NewLine); } } } else { - log += "Upgrade Failed: Could Not Backup Files" + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 95, "Upgrade Failed: Could Not Backup Files", "bg-danger"); + WriteLog(logFilePath, "Upgrade Failed: Could Not Backup Files" + Environment.NewLine); } } else { - log += "Upgrade Failed: Some Files Are Locked By The Hosting Environment" + Environment.NewLine; + UpdateOfflineContent(offlineFilePath, offlineTemplate, 95, "Upgrade Failed: Some Files Are Locked By The Hosting Environment", "bg-danger"); + WriteLog(logFilePath, "Upgrade Failed: Some Files Are Locked By The Hosting Environment" + Environment.NewLine); } + UpdateOfflineContent(offlineFilePath, offlineTemplate, 100, "Upgrade Process Finished, Reloading", success ? "" : "bg-danger"); + Thread.Sleep(3000); //wait for 3 seconds to complete the upgrade process. // bring the app back online if (File.Exists(Path.Combine(contentrootfolder, "app_offline.htm"))) { - log += "Restarting Application By Removing: " + Path.Combine(contentrootfolder, "app_offline.htm") + Environment.NewLine; + WriteLog(logFilePath, "Restarting Application By Removing: " + Path.Combine(contentrootfolder, "app_offline.htm") + Environment.NewLine); File.Delete(Path.Combine(contentrootfolder, "app_offline.htm")); } } else { - log += "Framework Upgrade Package Not Found Or " + Path.Combine(webrootfolder, "app_offline.bak") + " Does Not Exist" + Environment.NewLine; + WriteLog(logFilePath, "Framework Upgrade Package Not Found Or " + Path.Combine(webrootfolder, "app_offline.bak") + " Does Not Exist" + Environment.NewLine); } - log += "Upgrade Process Ended: " + DateTime.UtcNow.ToString() + Environment.NewLine; - - // create upgrade log file - string logfile = Path.Combine(deployfolder, Path.GetFileNameWithoutExtension(packagename) + ".log"); - if (File.Exists(logfile)) - { - File.Delete(logfile); - } - File.WriteAllText(logfile, log); + WriteLog(logFilePath, "Upgrade Process Ended: " + DateTime.UtcNow.ToString() + Environment.NewLine); } else { @@ -269,5 +290,21 @@ namespace Oqtane.Updater } return canAccess; } + + private static void UpdateOfflineContent(string filePath, string contentTemplate, int progress, string status, string progressClass = "") + { + var content = contentTemplate + .Replace("[BOOTSTRAPCSSURL]", Constants.BootstrapStylesheetUrl) + .Replace("[BOOTSTRAPCSSINTEGRITY]", Constants.BootstrapStylesheetIntegrity) + .Replace("[PROGRESS]", progress.ToString()) + .Replace("[PROGRESSCLASS]", progressClass) + .Replace("[STATUS]", status); + File.WriteAllText(filePath, content); + } + + private static void WriteLog(string logFilePath, string logContent) + { + File.AppendAllText(logFilePath, $"[{DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff")}] {logContent}"); + } } } diff --git a/README.md b/README.md index 1b011b82..89c79d8b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Latest Release -[5.2.0](https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.0) was released on July 25, 2024 and is a major release including 109 pull requests by 8 different contributors, pushing the total number of project commits all-time to over 5600. The Oqtane framework continues to evolve at a rapid pace to meet the needs of .NET developers. +[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. [![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,26 +14,46 @@ 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 +# Getting Started (Version 5.x) -**Using Version 5:** +**Installing using source code from the Dev/Master branch:** -- Install **[.NET 8.0.7 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)**. +- Install **[.NET 8.0.8 SDK](https://dotnet.microsoft.com/download/dotnet/8.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**. -- Clone the Oqtane dev branch source code to your local system. +- Clone (or download) the Oqtane Master or Dev branch source code to your local system. - Open the **Oqtane.sln** solution file. -- **Important:** Rebuild the entire solution before running it. +- **Important:** Rebuild the entire solution before running it (ie. Build / Rebuild Solution). -- Make sure you specify Oqtane.Server as the Startup Project +- Make sure you specify Oqtane.Server as the Startup Project. -- Run the application. +- Run the application... an Installation Wizard screen will be displayed which will allow you to configure your preferred database and create a host user account. + +**Developing a custom module:** + +- follow the instructions for installing using source code outlined above + +- login as the host user + +- navigate to Control Panel (gear icon at top-right of page), Admin Dashboard, Module Management + +- select Create Module + +- enter information corresponding to the module you wish to create and then select the Create button + +- make note of the Location where the code was generated and open the solution file in Visual Studio + +- Build / Rebuild Solution, ensure the Oqtane.Server is set as the Startup Project, and hit F5 to run the solution **Installing an official release:** +- all official releases of Oqtane are distributed on [GitHub](https://github.com/oqtane/oqtane.framework/releases). Releases include an Install.zip package for new installations and an Upgrade.zip for existing installations. + +- A detailed set of instructions for installing Oqtane on Azure is located here: [Installing Oqtane on Azure](https://blazorhelpwebsite.com/ViewBlogPost/1) + - A detailed set of instructions for installing Oqtane on IIS is located here: [Installing Oqtane on IIS](https://www.oqtane.org/Resources/Blog/PostId/542/installing-oqtane-on-iis) - Instructions for upgrading Oqtane are located here: [Upgrading Oqtane](https://www.oqtane.org/Resources/Blog/PostId/543/upgrading-oqtane) @@ -63,6 +83,10 @@ Backlog (TBD) - [ ] Folder Providers - [ ] Generative AI Integration +[5.2.1](https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.1) (Aug 22, 2024) +- [x] Stabilization improvements +- [x] Unzip support in File Management + [5.2.0](https://github.com/oqtane/oqtane.framework/releases/tag/v5.2.0) (Jul 25, 2024) - [x] Site Content Search - [x] RichTextEditor extensibility @@ -90,7 +114,7 @@ Backlog (TBD) ➡️ Full list and older versions can be found in the [docs roadmap](https://docs.oqtane.org/guides/roadmap/index.html) # Background -Oqtane was created by [Shaun Walker](https://www.linkedin.com/in/shaunbrucewalker/) and is inspired by the DotNetNuke web application framework. Initially created as a proof of concept, Oqtane is a native Blazor application written from the ground up using modern .NET Core technology and a Single Page Application (SPA) architecture. It is a modular application framework offering a fully dynamic page compositing model, multi-site support, designer friendly themes, and extensibility via third party modules. +Oqtane was created by [Shaun Walker](https://www.linkedin.com/in/shaunbrucewalker/) and is inspired by the DotNetNuke web application framework. Oqtane is a native Blazor application written from the ground up using modern .NET Core technology and a Single Page Application (SPA) architecture. It is a modular application framework offering a fully dynamic page compositing model, multi-site support, designer friendly themes, and extensibility via third party modules. # Reference Implementations @@ -106,7 +130,7 @@ The following diagram visualizes the client and server components in the Oqtane # Databases -As of version 2.1, Oqtane supports multiple relational database providers. +As of version 2.1 (June 2021) Oqtane supports multiple relational database providers - SQL Server, SQLite, MySQL, PostgreSQL ![Databases](https://github.com/oqtane/framework/blob/dev/screenshots/databases.png?raw=true "Oqtane Databases")