diff --git a/Oqtane.Client/Modules/Admin/Login/Index.razor b/Oqtane.Client/Modules/Admin/Login/Index.razor index f10c0203..f0a057d0 100644 --- a/Oqtane.Client/Modules/Admin/Login/Index.razor +++ b/Oqtane.Client/Modules/Admin/Login/Index.razor @@ -144,7 +144,7 @@ else user = await UserService.VerifyEmailAsync(user, PageState.QueryString["token"]); if (user != null) { - await logger.LogInformation(LogFunction.Security, "Email Verified For For Username {Username}", _username); + await logger.LogInformation(LogFunction.Security, "Email Verified For Username {Username}", _username); AddModuleMessage(Localizer["Success.Account.Verified"], MessageType.Info); } else diff --git a/Oqtane.Client/Modules/Admin/Modules/Export.razor b/Oqtane.Client/Modules/Admin/Modules/Export.razor index d2e90193..10831a56 100644 --- a/Oqtane.Client/Modules/Admin/Modules/Export.razor +++ b/Oqtane.Client/Modules/Admin/Modules/Export.razor @@ -5,24 +5,57 @@ @inject IStringLocalizer Localizer @inject IStringLocalizer SharedLocalizer -
-
- -
- + + +
+
+ +
+ +
+
-
-
+
+ + @SharedLocalizer["Cancel"] + + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ + @SharedLocalizer["Cancel"] +
+ + - -@SharedLocalizer["Cancel"] @code { private string _content = string.Empty; + private FileManager _filemanager; + private string _filename = string.Empty; + public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Edit; public override string Title => "Export Content"; - private async Task ExportModule() + protected override void OnInitialized() + { + _filename = Utilities.GetFriendlyUrl(ModuleState.Title); + } + + private async Task ExportText() { try { @@ -35,4 +68,34 @@ AddModuleMessage(Localizer["Error.Module.Export"], MessageType.Error); } } + + private async Task ExportFile() + { + try + { + var folderid = _filemanager.GetFolderId(); + if (folderid != -1 && !string.IsNullOrEmpty(_filename)) + { + var fileid = await ModuleService.ExportModuleAsync(ModuleState.ModuleId, PageState.Page.PageId, folderid, _filename); + if (fileid != -1) + { + AddModuleMessage(Localizer["Success.Content.Export"], MessageType.Success); + } + else + { + AddModuleMessage(Localizer["Error.Module.Export"], MessageType.Error); + } + } + else + { + AddModuleMessage(Localizer["Message.Content.Export"], MessageType.Warning); + } + } + catch (Exception ex) + { + await logger.LogError(ex, "Error Exporting Module {ModuleId} {Error}", ModuleState.ModuleId, ex.Message); + AddModuleMessage(Localizer["Error.Module.Export"], MessageType.Error); + } + } + } diff --git a/Oqtane.Client/Modules/Admin/Modules/Import.razor b/Oqtane.Client/Modules/Admin/Modules/Import.razor index 04b2557a..eacec260 100644 --- a/Oqtane.Client/Modules/Admin/Modules/Import.razor +++ b/Oqtane.Client/Modules/Admin/Modules/Import.razor @@ -2,20 +2,27 @@ @inherits ModuleBase @inject NavigationManager NavigationManager @inject IModuleService ModuleService +@inject IFileService FileService @inject IStringLocalizer Localizer @inject IStringLocalizer SharedLocalizer
- + +
+ +
+
+
+
+
-
- +
@SharedLocalizer["Cancel"]
@@ -28,6 +35,12 @@ public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Edit; public override string Title => "Import Content"; + private async Task OnSelectFile(int fileId) + { + var bytes = await FileService.DownloadFileAsync(fileId); + _content = System.Text.Encoding.UTF8.GetString(bytes, 0, bytes.Length); + } + private async Task ImportModule() { validated = true; diff --git a/Oqtane.Client/Modules/Admin/Modules/Settings.razor b/Oqtane.Client/Modules/Admin/Modules/Settings.razor index b7cf4f4c..297a365a 100644 --- a/Oqtane.Client/Modules/Admin/Modules/Settings.razor +++ b/Oqtane.Client/Modules/Admin/Modules/Settings.razor @@ -101,15 +101,15 @@
- +
- +
- +
- +
diff --git a/Oqtane.Client/Modules/Admin/Register/Index.razor b/Oqtane.Client/Modules/Admin/Register/Index.razor index 13920441..712ba186 100644 --- a/Oqtane.Client/Modules/Admin/Register/Index.razor +++ b/Oqtane.Client/Modules/Admin/Register/Index.razor @@ -8,88 +8,92 @@ @inject IStringLocalizer SharedLocalizer @inject ISettingService SettingService -@if (PageState.Site.AllowRegistration) +@if (_initialized) { - if (!_userCreated) + @if (PageState.Site.AllowRegistration) { - if (PageState.User != null) + if (!_userCreated) { - - } - else - { - -
-
-
- -
- + if (PageState.User != null) + { + + } + else + { + + +
+
+ +
+ +
-
-
- -
-
- - +
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
-
- -
-
- - -
-
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
-
- - - @if (_allowsitelogin) - {
+ + + @if (_allowsitelogin) + { +
-
- @Localizer["Login"] - } - +
+ @Localizer["Login"] + } + + } } - } -} -else -{ - + } + else + { + + } } @code { + private bool _initialized = false; private List _timezones; private string _passwordrequirements; private string _username = string.Empty; @@ -113,6 +117,7 @@ else _allowsitelogin = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AllowSiteLogin", "true")); _timezones = await TimeZoneService.GetTimeZonesAsync(); _timezoneid = PageState.Site.TimeZoneId; + _initialized = true; } protected override void OnParametersSet() diff --git a/Oqtane.Client/Modules/Admin/UrlMappings/Add.razor b/Oqtane.Client/Modules/Admin/UrlMappings/Add.razor index 8f43985d..68956c40 100644 --- a/Oqtane.Client/Modules/Admin/UrlMappings/Add.razor +++ b/Oqtane.Client/Modules/Admin/UrlMappings/Add.razor @@ -8,7 +8,7 @@
- +
@@ -17,7 +17,7 @@
- +
diff --git a/Oqtane.Client/Modules/Admin/UrlMappings/Edit.razor b/Oqtane.Client/Modules/Admin/UrlMappings/Edit.razor index 78a062f9..c6cb2e0e 100644 --- a/Oqtane.Client/Modules/Admin/UrlMappings/Edit.razor +++ b/Oqtane.Client/Modules/Admin/UrlMappings/Edit.razor @@ -8,13 +8,13 @@
- +
- +
diff --git a/Oqtane.Client/Modules/Admin/Users/Edit.razor b/Oqtane.Client/Modules/Admin/Users/Edit.razor index 549059e4..c6401a47 100644 --- a/Oqtane.Client/Modules/Admin/Users/Edit.razor +++ b/Oqtane.Client/Modules/Admin/Users/Edit.razor @@ -18,13 +18,13 @@
- +
- +
@@ -33,7 +33,7 @@
- +
@@ -42,13 +42,22 @@
- +
- + +
+ +
+
+
+
@@ -68,7 +77,7 @@ @if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) {
- +
- +
@@ -167,6 +176,7 @@ private string _togglepassword = string.Empty; private string _confirm = string.Empty; private string _email = string.Empty; + private string _confirmed = string.Empty; private string _displayname = string.Empty; private string _timezoneid = string.Empty; private string _isdeleted; @@ -204,6 +214,7 @@ { _username = user.Username; _email = user.Email; + _confirmed = user.EmailConfirmed.ToString(); _displayname = user.DisplayName; _timezoneid = PageState.User.TimeZoneId; _isdeleted = user.IsDeleted.ToString(); @@ -255,6 +266,7 @@ user.Username = _username; user.Password = _password; user.Email = _email; + user.EmailConfirmed = bool.Parse(_confirmed); user.DisplayName = string.IsNullOrWhiteSpace(_displayname) ? _username : _displayname; user.TimeZoneId = _timezoneid; if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) diff --git a/Oqtane.Client/Modules/ModuleBase.cs b/Oqtane.Client/Modules/ModuleBase.cs index 49d6b00b..39681067 100644 --- a/Oqtane.Client/Modules/ModuleBase.cs +++ b/Oqtane.Client/Modules/ModuleBase.cs @@ -1,16 +1,16 @@ -using Microsoft.AspNetCore.Components; -using Oqtane.Shared; -using Oqtane.Models; -using System.Threading.Tasks; -using Oqtane.Services; using System; -using Oqtane.Enums; -using Oqtane.UI; using System.Collections.Generic; -using Microsoft.JSInterop; -using System.Linq; using System.Dynamic; -using System.Reflection; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using Oqtane.Enums; +using Oqtane.Models; +using Oqtane.Services; +using Oqtane.Shared; +using Oqtane.UI; namespace Oqtane.Modules { @@ -79,18 +79,21 @@ namespace Oqtane.Modules { List resources = null; var type = GetType(); - if (type.BaseType == typeof(ModuleBase)) + if (type.IsSubclassOf(typeof(ModuleBase))) { - if (PageState.Page.Resources != null) + if (type.IsSubclassOf(typeof(ModuleControlBase))) { - resources = PageState.Page.Resources.Where(item => item.ResourceType == ResourceType.Script && item.Level == ResourceLevel.Module && item.Namespace == type.Namespace).ToList(); + if (Resources != null) + { + resources = Resources.Where(item => item.ResourceType == ResourceType.Script).ToList(); + } } - } - else // modulecontrolbase - { - if (Resources != null) + else // ModuleBase { - resources = Resources.Where(item => item.ResourceType == ResourceType.Script).ToList(); + if (PageState.Page.Resources != null) + { + resources = PageState.Page.Resources.Where(item => item.ResourceType == ResourceType.Script && item.Level == ResourceLevel.Module && item.Namespace == type.Namespace).ToList(); + } } } if (resources != null && resources.Any()) @@ -421,70 +424,80 @@ namespace Oqtane.Modules public string ReplaceTokens(string content, object obj) { - var tokens = new List(); - var pos = content.IndexOf("["); - if (pos != -1) - { - if (content.IndexOf("]", pos) != -1) - { - var token = content.Substring(pos, content.IndexOf("]", pos) - pos + 1); - if (token.Contains(":")) - { - tokens.Add(token.Substring(1, token.Length - 2)); - } - } - pos = content.IndexOf("[", pos + 1); - } - if (tokens.Count != 0) - { - foreach (string token in tokens) - { - var segments = token.Split(":"); - if (segments.Length >= 2 && segments.Length <= 3) - { - var objectName = string.Join(":", segments, 0, segments.Length - 1); - var propertyName = segments[segments.Length - 1]; - var propertyValue = ""; + // Using StringBuilder avoids the performance penalty of repeated string allocations + // that occur with string.Replace or string concatenation inside loops. + var sb = new StringBuilder(); + var cache = new Dictionary(); // Cache to store resolved tokens + int index = 0; - switch (objectName) + // Loop through content to find and replace all tokens + while (index < content.Length) + { + int start = content.IndexOf('[', index); // Find start of token + if (start == -1) + { + sb.Append(content, index, content.Length - index); // Append remaining content + break; + } + + int end = content.IndexOf(']', start); // Find end of token + if (end == -1) + { + sb.Append(content, index, content.Length - index); // Append unmatched content + break; + } + + sb.Append(content, index, start - index); // Append content before token + + string token = content.Substring(start + 1, end - start - 1); // Extract token without brackets + string[] parts = token.Split('|', 2); // Separate default fallback if present + string key = parts[0]; + string fallback = parts.Length == 2 ? parts[1] : null; + + if (!cache.TryGetValue(token, out string replacement)) // Check cache first + { + replacement = "[" + token + "]"; // Default replacement is original token + string[] segments = key.Split(':'); + + if (segments.Length >= 2) + { + object current = GetTarget(segments[0], obj); // Start from root object + for (int i = 1; i < segments.Length && current != null; i++) { - case "ModuleState": - propertyValue = ModuleState.GetType().GetProperty(propertyName)?.GetValue(ModuleState, null).ToString(); - break; - case "PageState": - propertyValue = PageState.GetType().GetProperty(propertyName)?.GetValue(PageState, null).ToString(); - break; - case "PageState:Alias": - propertyValue = PageState.Alias.GetType().GetProperty(propertyName)?.GetValue(PageState.Alias, null).ToString(); - break; - case "PageState:Site": - propertyValue = PageState.Site.GetType().GetProperty(propertyName)?.GetValue(PageState.Site, null).ToString(); - break; - case "PageState:Page": - propertyValue = PageState.Page.GetType().GetProperty(propertyName)?.GetValue(PageState.Page, null).ToString(); - break; - case "PageState:User": - propertyValue = PageState.User?.GetType().GetProperty(propertyName)?.GetValue(PageState.User, null).ToString(); - break; - case "PageState:Route": - propertyValue = PageState.Route.GetType().GetProperty(propertyName)?.GetValue(PageState.Route, null).ToString(); - break; - default: - if (obj != null && obj.GetType().Name == objectName) - { - propertyValue = obj.GetType().GetProperty(propertyName)?.GetValue(obj, null).ToString(); - } - break; - } - if (propertyValue != null) - { - content = content.Replace("[" + token + "]", propertyValue); + var type = current.GetType(); + var prop = type.GetProperty(segments[i]); + current = prop?.GetValue(current); } + if (current != null) + { + replacement = current.ToString(); + } + else if (fallback != null) + { + replacement = fallback; // Use fallback if available + } } + cache[token] = replacement; // Store in cache } + + sb.Append(replacement); // Append replacement value + index = end + 1; // Move index past token } - return content; + + return sb.ToString(); + } + + // Resolve the object instance for a given object name + // Easy to extend with additional object types + private object GetTarget(string name, object obj) + { + return name switch + { + "ModuleState" => ModuleState, + "PageState" => PageState, + _ => (obj != null && obj.GetType().Name == name) ? obj : null // Fallback to obj + }; } // date methods diff --git a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx index 0b1a8780..c60c0716 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx @@ -121,10 +121,10 @@ Forgot Password - User Account Verified Successfully. You Can Now Login With Your Username And Password Below. + User Account Email Address Verified Successfully. You Can Now Login With Your Username And Password. - User Account Could Not Be Verified. Please Contact Your Administrator For Further Instructions. + User Account Email Address Could Not Be Verified. Please Contact Your Administrator For Further Instructions. User Account Linked Successfully. You Can Now Login With Your External Login Below. @@ -133,7 +133,7 @@ External Login Could Not Be Linked. Please Contact Your Administrator For Further Instructions. - Login Failed. Please Remember That Passwords Are Case Sensitive. If You Have Attempted To Sign In Multiple Times Unsuccessfully, Your Account Will Be Locked Out For A Period Of Time. Note That User Accounts Require Verification When They Are Initially Created So You May Wish To Check Your Email If You Are A New User. + Login Failed. Please Remember That Passwords Are Case Sensitive. If You Have Attempted To Sign In Multiple Times Unsuccessfully, Your Account Will Be Locked Out For A Period Of Time. Note That User Accounts Often Require Email Address Verification So You May Wish To Check Your Email For A Notification. Please Provide All Required Fields diff --git a/Oqtane.Client/Resources/Modules/Admin/Modules/Export.resx b/Oqtane.Client/Resources/Modules/Admin/Modules/Export.resx index 3ed705f8..23378774 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Modules/Export.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Modules/Export.resx @@ -121,7 +121,7 @@ Export - The Exported Module Content + Select the Export option and you will be able to view the module content Content: @@ -135,4 +135,25 @@ Export Content + + Content + + + File + + + Folder: + + + Select a folder where you wish to save the exported content + + + Please Select A Folder And Provide A Filename Before Choosing Export + + + Filename: + + + Specify a name for the file (without an extension) + \ No newline at end of file diff --git a/Oqtane.Client/Resources/Modules/Admin/UrlMappings/Add.resx b/Oqtane.Client/Resources/Modules/Admin/UrlMappings/Add.resx index a8de1c59..aeae4b29 100644 --- a/Oqtane.Client/Resources/Modules/Admin/UrlMappings/Add.resx +++ b/Oqtane.Client/Resources/Modules/Admin/UrlMappings/Add.resx @@ -121,10 +121,10 @@ Redirect To: - A relative or absolute Url where the user will be redirected. Use "/" for site root path. + A Url where the user will be redirected (absolute or relative). Use '/' for site root path. - An absolute Url for this site + A Url identifying a path to a specific page in the site (absolute or relative) Url: diff --git a/Oqtane.Client/Resources/Modules/Admin/UrlMappings/Edit.resx b/Oqtane.Client/Resources/Modules/Admin/UrlMappings/Edit.resx index 0b5d799a..b51c4366 100644 --- a/Oqtane.Client/Resources/Modules/Admin/UrlMappings/Edit.resx +++ b/Oqtane.Client/Resources/Modules/Admin/UrlMappings/Edit.resx @@ -121,10 +121,10 @@ Redirect To: - A relative or absolute Url where the user will be redirected. Use "/" for site root path. + A Url where the user will be redirected (absolute or relative). Use '/' for site root path. - A relative Url identifying a path to a specific page in the site + A Url identifying a path to a specific page in the site (absolute or relative) Url: diff --git a/Oqtane.Client/Resources/Modules/Admin/Users/Edit.resx b/Oqtane.Client/Resources/Modules/Admin/Users/Edit.resx index df4ccc95..ff38ec85 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Users/Edit.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Users/Edit.resx @@ -216,4 +216,10 @@ The user's time zone + + Confirmed? + + + Indicates if the user's email is verified + \ No newline at end of file diff --git a/Oqtane.Client/Services/Interfaces/IModuleService.cs b/Oqtane.Client/Services/Interfaces/IModuleService.cs index 46777fdf..ea6beab3 100644 --- a/Oqtane.Client/Services/Interfaces/IModuleService.cs +++ b/Oqtane.Client/Services/Interfaces/IModuleService.cs @@ -56,7 +56,18 @@ namespace Oqtane.Services /// Exports a given module /// /// - /// module in JSON + /// + /// module content in JSON format Task ExportModuleAsync(int moduleId, int pageId); + + /// + /// Exports a given module + /// + /// + /// + /// + /// + /// file id + Task ExportModuleAsync(int moduleId, int pageId, int folderId, string filename); } } diff --git a/Oqtane.Client/Services/ModuleService.cs b/Oqtane.Client/Services/ModuleService.cs index e1914004..ac093bed 100644 --- a/Oqtane.Client/Services/ModuleService.cs +++ b/Oqtane.Client/Services/ModuleService.cs @@ -47,8 +47,13 @@ namespace Oqtane.Services } public async Task ExportModuleAsync(int moduleId, int pageId) -{ + { return await GetStringAsync($"{Apiurl}/export?moduleid={moduleId}&pageid={pageId}"); } + + public async Task ExportModuleAsync(int moduleId, int pageId, int folderId, string filename) + { + return await PostJsonAsync($"{Apiurl}/export?moduleid={moduleId}&pageid={pageId}&folderid={folderId}&filename={filename}", null); + } } } diff --git a/Oqtane.Client/Themes/ThemeBase.cs b/Oqtane.Client/Themes/ThemeBase.cs index aa5eaf5b..4359f48e 100644 --- a/Oqtane.Client/Themes/ThemeBase.cs +++ b/Oqtane.Client/Themes/ThemeBase.cs @@ -43,18 +43,21 @@ namespace Oqtane.Themes { List resources = null; var type = GetType(); - if (type.BaseType == typeof(ThemeBase)) + if (type.IsSubclassOf(typeof(ThemeBase))) { - if (PageState.Page.Resources != null) + if (type.IsSubclassOf(typeof(ThemeControlBase)) || type.IsSubclassOf(typeof(ContainerBase))) { - resources = PageState.Page.Resources.Where(item => item.ResourceType == ResourceType.Script && item.Level == ResourceLevel.Page && item.Namespace == type.Namespace).ToList(); + if (Resources != null) + { + resources = Resources.Where(item => item.ResourceType == ResourceType.Script).ToList(); + } } - } - else // themecontrolbase, containerbase - { - if (Resources != null) + else // ThemeBase { - resources = Resources.Where(item => item.ResourceType == ResourceType.Script).ToList(); + if (PageState.Page.Resources != null) + { + resources = PageState.Page.Resources.Where(item => item.ResourceType == ResourceType.Script && item.Level == ResourceLevel.Page && item.Namespace == type.Namespace).ToList(); + } } } if (resources != null && resources.Any()) diff --git a/Oqtane.Package/release.cmd b/Oqtane.Package/release.cmd index 0452d806..8bc6518a 100644 --- a/Oqtane.Package/release.cmd +++ b/Oqtane.Package/release.cmd @@ -9,6 +9,8 @@ nuget.exe pack Oqtane.Framework.nuspec del /F/Q/S "..\Oqtane.Server\bin\Release\net9.0\publish" > NUL rmdir /Q/S "..\Oqtane.Server\bin\Release\net9.0\publish" dotnet publish ..\Oqtane.Server\Oqtane.Server.csproj /p:Configuration=Release +del /F/Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\Content" > NUL +rmdir /Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\Content" del /F/Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\wwwroot\Content" > NUL rmdir /Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\wwwroot\Content" setlocal ENABLEDELAYEDEXPANSION diff --git a/Oqtane.Server/Controllers/FileController.cs b/Oqtane.Server/Controllers/FileController.cs index 7ead95d7..f0b72f22 100644 --- a/Oqtane.Server/Controllers/FileController.cs +++ b/Oqtane.Server/Controllers/FileController.cs @@ -22,7 +22,6 @@ using Microsoft.AspNetCore.Cors; using System.IO.Compression; using Oqtane.Services; using Microsoft.Extensions.Primitives; -using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Net.Http.Headers; // ReSharper disable StringIndexOfIsCultureSpecific.1 diff --git a/Oqtane.Server/Controllers/ModuleController.cs b/Oqtane.Server/Controllers/ModuleController.cs index 02f6f8c6..61f69f33 100644 --- a/Oqtane.Server/Controllers/ModuleController.cs +++ b/Oqtane.Server/Controllers/ModuleController.cs @@ -9,6 +9,7 @@ using Oqtane.Infrastructure; using Oqtane.Repository; using Oqtane.Security; using System.Net; +using System.IO; namespace Oqtane.Controllers { @@ -20,18 +21,22 @@ namespace Oqtane.Controllers private readonly IPageRepository _pages; private readonly IModuleDefinitionRepository _moduleDefinitions; private readonly ISettingRepository _settings; + private readonly IFolderRepository _folders; + private readonly IFileRepository _files; private readonly IUserPermissions _userPermissions; private readonly ISyncManager _syncManager; private readonly ILogManager _logger; private readonly Alias _alias; - public ModuleController(IModuleRepository modules, IPageModuleRepository pageModules, IPageRepository pages, IModuleDefinitionRepository moduleDefinitions, ISettingRepository settings, IUserPermissions userPermissions, ITenantManager tenantManager, ISyncManager syncManager, ILogManager logger) + public ModuleController(IModuleRepository modules, IPageModuleRepository pageModules, IPageRepository pages, IModuleDefinitionRepository moduleDefinitions, ISettingRepository settings, IFolderRepository folders, IFileRepository files, IUserPermissions userPermissions, ITenantManager tenantManager, ISyncManager syncManager, ILogManager logger) { _modules = modules; _pageModules = pageModules; _pages = pages; _moduleDefinitions = moduleDefinitions; _settings = settings; + _folders = folders; + _files = files; _userPermissions = userPermissions; _syncManager = syncManager; _logger = logger; @@ -248,6 +253,61 @@ namespace Oqtane.Controllers return content; } + // POST api//export?moduleid=x&pageid=y&folderid=z&filename=a + [HttpPost("export")] + [Authorize(Roles = RoleNames.Registered)] + public int Export(int moduleid, int pageid, int folderid, string filename) + { + var fileid = -1; + var module = _modules.GetModule(moduleid); + if (module != null && module.SiteId == _alias.SiteId && _userPermissions.IsAuthorized(User, module.SiteId, EntityNames.Page, pageid, PermissionNames.Edit) && + _userPermissions.IsAuthorized(User, module.SiteId, EntityNames.Folder, folderid, PermissionNames.Edit) && !string.IsNullOrEmpty(filename)) + { + // get content + var content = _modules.ExportModule(moduleid); + + // get folder + var folder = _folders.GetFolder(folderid, false); + string folderPath = _folders.GetFolderPath(folder); + if (!Directory.Exists(folderPath)) + { + Directory.CreateDirectory(folderPath); + } + + // create json file + filename = Utilities.GetFriendlyUrl(Path.GetFileNameWithoutExtension(filename)) + ".json"; + string filepath = Path.Combine(folderPath, filename); + if (System.IO.File.Exists(filepath)) + { + System.IO.File.Delete(filepath); + } + System.IO.File.WriteAllText(filepath, content); + + // register file + var file = _files.GetFile(folderid, filename); + if (file == null) + { + file = new Models.File { FolderId = folderid, Name = filename, Extension = "json", Size = (int)new FileInfo(filepath).Length, ImageWidth = 0, ImageHeight = 0 }; + _files.AddFile(file); + } + else + { + file.Size = (int)new FileInfo(filepath).Length; + _files.UpdateFile(file); + } + fileid = file.FileId; + + _logger.Log(LogLevel.Information, this, LogFunction.Read, "Content Exported For Module {ModuleId} To Folder {FolderId}", moduleid, folderid); + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Export Attempt For Module {Module} To Folder {FolderId}", moduleid, folderid); + HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; + } + + return fileid; + } + // POST api//import?moduleid=x&pageid=y [HttpPost("import")] [Authorize(Roles = RoleNames.Registered)] diff --git a/Oqtane.Server/Controllers/SettingController.cs b/Oqtane.Server/Controllers/SettingController.cs index 2579d379..fd8708c1 100644 --- a/Oqtane.Server/Controllers/SettingController.cs +++ b/Oqtane.Server/Controllers/SettingController.cs @@ -24,26 +24,50 @@ namespace Oqtane.Controllers private readonly IPageModuleRepository _pageModules; private readonly IUserPermissions _userPermissions; private readonly ISyncManager _syncManager; - private readonly IAliasAccessor _aliasAccessor; - private readonly IOptionsMonitorCache _cookieCache; - private readonly IOptionsMonitorCache _oidcCache; - private readonly IOptionsMonitorCache _oauthCache; - private readonly IOptionsMonitorCache _identityCache; + + private readonly IOptions _cookieOptions; + private readonly IOptionsSnapshot _cookieOptionsSnapshot; + private readonly IOptionsMonitorCache _cookieOptionsMonitorCache; + + private readonly IOptions _oidcOptions; + private readonly IOptionsSnapshot _oidcOptionsSnapshot; + private readonly IOptionsMonitorCache _oidcOptionsMonitorCache; + + private readonly IOptions _oauthOptions; + private readonly IOptionsSnapshot _oauthOptionsSnapshot; + private readonly IOptionsMonitorCache _oauthOptionsMonitorCache; + + private readonly IOptions _identityOptions; + private readonly IOptionsSnapshot _identityOptionsSnapshot; + private readonly IOptionsMonitorCache _identityOptionsMonitorCache; + private readonly ILogManager _logger; private readonly Alias _alias; private readonly string _visitorCookie; - public SettingController(ISettingRepository settings, IPageModuleRepository pageModules, IUserPermissions userPermissions, ITenantManager tenantManager, ISyncManager syncManager, IAliasAccessor aliasAccessor, IOptionsMonitorCache cookieCache, IOptionsMonitorCache oidcCache, IOptionsMonitorCache oauthCache, IOptionsMonitorCache identityCache, ILogManager logger) + public SettingController(ISettingRepository settings, IPageModuleRepository pageModules, IUserPermissions userPermissions, ITenantManager tenantManager, ISyncManager syncManager, + IOptions cookieOptions, IOptionsSnapshot cookieOptionsSnapshot, IOptionsMonitorCache cookieOptionsMonitorCache, + IOptions oidcOptions, IOptionsSnapshot oidcOptionsSnapshot, IOptionsMonitorCache oidcOptionsMonitorCache, + IOptions oauthOptions, IOptionsSnapshot oauthOptionsSnapshot, IOptionsMonitorCache oauthOptionsMonitorCache, + IOptions identityOptions, IOptionsSnapshot identityOptionsSnapshot, IOptionsMonitorCache identityOptionsMonitorCache, + ILogManager logger) { _settings = settings; _pageModules = pageModules; _userPermissions = userPermissions; _syncManager = syncManager; - _aliasAccessor = aliasAccessor; - _cookieCache = cookieCache; - _oidcCache = oidcCache; - _oauthCache = oauthCache; - _identityCache = identityCache; + _cookieOptions = cookieOptions; + _cookieOptionsSnapshot = cookieOptionsSnapshot; + _cookieOptionsMonitorCache = cookieOptionsMonitorCache; + _oidcOptions = oidcOptions; + _oidcOptionsSnapshot = oidcOptionsSnapshot; + _oidcOptionsMonitorCache = oidcOptionsMonitorCache; + _oauthOptions = oauthOptions; + _oauthOptionsSnapshot = oauthOptionsSnapshot; + _oauthOptionsMonitorCache = oauthOptionsMonitorCache; + _identityOptions = identityOptions; + _identityOptionsSnapshot = identityOptionsSnapshot; + _identityOptionsMonitorCache = identityOptionsMonitorCache; _logger = logger; _alias = tenantManager.GetAlias(); _visitorCookie = Constants.VisitorCookiePrefix + _alias.SiteId.ToString(); @@ -210,21 +234,21 @@ namespace Oqtane.Controllers [Authorize(Roles = RoleNames.Admin)] public void Clear() { - // clear SiteOptionsCache for each option type - var cookieCache = new SiteOptionsCache(_aliasAccessor); - cookieCache.Clear(); - var oidcCache = new SiteOptionsCache(_aliasAccessor); - oidcCache.Clear(); - var oauthCache = new SiteOptionsCache(_aliasAccessor); - oauthCache.Clear(); - var identityCache = new SiteOptionsCache(_aliasAccessor); - identityCache.Clear(); + (_cookieOptions as SiteOptionsManager).Reset(); + (_cookieOptionsSnapshot as SiteOptionsManager).Reset(); + _cookieOptionsMonitorCache.Clear(); - // clear IOptionsMonitorCache for each option type - _cookieCache.Clear(); - _oidcCache.Clear(); - _oauthCache.Clear(); - _identityCache.Clear(); + (_oidcOptions as SiteOptionsManager).Reset(); + (_oidcOptionsSnapshot as SiteOptionsManager).Reset(); + _oidcOptionsMonitorCache.Clear(); + + (_oauthOptions as SiteOptionsManager).Reset(); + (_oauthOptionsSnapshot as SiteOptionsManager).Reset(); + _oauthOptionsMonitorCache.Clear(); + + (_identityOptions as SiteOptionsManager).Reset(); + (_identityOptionsSnapshot as SiteOptionsManager).Reset(); + _identityOptionsMonitorCache.Clear(); _logger.Log(LogLevel.Information, this, LogFunction.Other, "Site Options Cache Cleared"); } diff --git a/Oqtane.Server/Controllers/UserController.cs b/Oqtane.Server/Controllers/UserController.cs index 7a0f3bfd..859bd50c 100644 --- a/Oqtane.Server/Controllers/UserController.cs +++ b/Oqtane.Server/Controllers/UserController.cs @@ -131,7 +131,7 @@ namespace Oqtane.Controllers filtered.TwoFactorCode = ""; filtered.SecurityStamp = ""; - // include private properties if authenticated user is accessing their own user account os is an administrator + // include private properties if authenticated user is accessing their own user account or is an administrator if (_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || _userPermissions.GetUser(User).UserId == user.UserId) { filtered.Email = user.Email; @@ -140,6 +140,7 @@ namespace Oqtane.Controllers filtered.LastLoginOn = user.LastLoginOn; filtered.LastIPAddress = user.LastIPAddress; filtered.TwoFactorRequired = user.TwoFactorRequired; + filtered.EmailConfirmed = user.EmailConfirmed; filtered.Roles = user.Roles; filtered.CreatedBy = user.CreatedBy; filtered.CreatedOn = user.CreatedOn; @@ -200,10 +201,15 @@ namespace Oqtane.Controllers [Authorize] public async Task Put(int id, [FromBody] User user) { - if (ModelState.IsValid && user.SiteId == _tenantManager.GetAlias().SiteId && user.UserId == id && _users.GetUser(user.UserId, false) != null + var existing = _userManager.GetUser(user.UserId, user.SiteId); + if (ModelState.IsValid && user.SiteId == _tenantManager.GetAlias().SiteId && user.UserId == id && existing != null && (_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || User.Identity.Name == user.Username)) { - user.EmailConfirmed = User.IsInRole(RoleNames.Admin); + // only authorized users can update the email confirmation + if (!_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin)) + { + user.EmailConfirmed = existing.EmailConfirmed; + } user = await _userManager.UpdateUser(user); } else diff --git a/Oqtane.Server/Extensions/OqtaneSiteIdentityBuilderExtensions.cs b/Oqtane.Server/Extensions/OqtaneSiteIdentityBuilderExtensions.cs index 6234d2fb..d00c01ae 100644 --- a/Oqtane.Server/Extensions/OqtaneSiteIdentityBuilderExtensions.cs +++ b/Oqtane.Server/Extensions/OqtaneSiteIdentityBuilderExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using Oqtane.Models; using Microsoft.AspNetCore.Identity; using System; diff --git a/Oqtane.Server/Infrastructure/Options/SiteOptionsCache.cs b/Oqtane.Server/Infrastructure/Options/SiteOptionsCache.cs index e4737d9a..5c802fa9 100644 --- a/Oqtane.Server/Infrastructure/Options/SiteOptionsCache.cs +++ b/Oqtane.Server/Infrastructure/Options/SiteOptionsCache.cs @@ -19,7 +19,6 @@ namespace Oqtane.Infrastructure { var cache = map.GetOrAdd(GetKey(), new OptionsCache()); cache.Clear(); - } public TOptions GetOrAdd(string name, Func createOptions) diff --git a/Oqtane.Server/Managers/UserManager.cs b/Oqtane.Server/Managers/UserManager.cs index 84679d23..5e1e6e64 100644 --- a/Oqtane.Server/Managers/UserManager.cs +++ b/Oqtane.Server/Managers/UserManager.cs @@ -65,7 +65,12 @@ namespace Oqtane.Managers { user.SiteId = siteid; user.Roles = GetUserRoles(user.UserId, user.SiteId); - user.SecurityStamp = _identityUserManager.FindByNameAsync(user.Username).GetAwaiter().GetResult()?.SecurityStamp; + var identityuser = _identityUserManager.FindByNameAsync(user.Username).GetAwaiter().GetResult(); + if (identityuser != null) + { + user.SecurityStamp = identityuser.SecurityStamp; + user.EmailConfirmed = identityuser.EmailConfirmed; + } user.Settings = _settings.GetSettings(EntityNames.User, user.UserId) .ToDictionary(setting => setting.SettingName, setting => setting.SettingValue); } @@ -245,22 +250,30 @@ namespace Oqtane.Managers { 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) - { - string token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser); - string url = alias.Protocol + alias.Name + "/login?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); - string body = "Dear " + user.DisplayName + ",\n\nIn Order To Verify The Email Address Associated To Your User Account Please Click The Link Displayed Below:\n\n" + url + "\n\nThank You!"; - var notification = new Notification(user.SiteId, user, "User Account Verification", body); - _notifications.AddNotification(notification); - } } if (user.EmailConfirmed) { - var emailConfirmationToken = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser); - await _identityUserManager.ConfirmEmailAsync(identityuser, emailConfirmationToken); + if (!identityuser.EmailConfirmed) + { + var emailConfirmationToken = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser); + await _identityUserManager.ConfirmEmailAsync(identityuser, emailConfirmationToken); + + string body = "Dear " + user.DisplayName + ",\n\nThe Email Address For Your User Account Has Been Verified. You Can Now Login With Your Username And Password."; + var notification = new Notification(user.SiteId, user, "User Account Verification", body); + _notifications.AddNotification(notification); + } + } + else + { + identityuser.EmailConfirmed = false; + await _identityUserManager.UpdateAsync(identityuser); // security stamp not updated + + string token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser); + string url = alias.Protocol + alias.Name + "/login?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); + string body = "Dear " + user.DisplayName + ",\n\nIn Order To Verify The Email Address Associated To Your User Account Please Click The Link Displayed Below:\n\n" + url + "\n\nThank You!"; + var notification = new Notification(user.SiteId, user, "User Account Verification", body); + _notifications.AddNotification(notification); } user = _users.UpdateUser(user); diff --git a/Oqtane.Server/Oqtane.Server.csproj b/Oqtane.Server/Oqtane.Server.csproj index 76bb68c8..2068c0ea 100644 --- a/Oqtane.Server/Oqtane.Server.csproj +++ b/Oqtane.Server/Oqtane.Server.csproj @@ -48,7 +48,7 @@ - + diff --git a/Oqtane.Shared/Models/Folder.cs b/Oqtane.Shared/Models/Folder.cs index da875219..47177cdb 100644 --- a/Oqtane.Shared/Models/Folder.cs +++ b/Oqtane.Shared/Models/Folder.cs @@ -43,7 +43,7 @@ namespace Oqtane.Models public string Path { get; set; } /// - /// Sorting order of the folder + /// Sorting order of the folder ** not used as folders are sorted in alphabetical order ** /// public int Order { get; set; } diff --git a/Oqtane.Shared/Models/Result.cs b/Oqtane.Shared/Models/Result.cs index d550f2c4..8500d3ad 100644 --- a/Oqtane.Shared/Models/Result.cs +++ b/Oqtane.Shared/Models/Result.cs @@ -6,6 +6,8 @@ namespace Oqtane.Models public string Message { get; set; } + public Result() {} + public Result(bool success) { Success = success; diff --git a/README.md b/README.md index a69b4390..10112a56 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Oqtane is being developed based on some fundamental principles which are outline # Latest Release -[6.1.2](https://github.com/oqtane/oqtane.framework/releases/tag/v6.1.2) was released on April 10, 2025 and is a maintenance release including 41 pull requests by 3 different contributors, pushing the total number of project commits all-time to over 6500. The Oqtane framework continues to evolve at a rapid pace to meet the needs of .NET developers. +[6.1.3](https://github.com/oqtane/oqtane.framework/releases/tag/v6.1.3) was released on May 29, 2025 and is a maintenance release including 59 pull requests by 5 different contributors, pushing the total number of project commits all-time to over 6600. The Oqtane framework continues to evolve at a rapid pace to meet the needs of .NET developers. # Try It Now! @@ -22,11 +22,11 @@ Microsoft's Public Cloud (requires an Azure account) A free ASP.NET hosting account. No hidden fees. No credit card required. [![Deploy to MonsterASP.NET](https://www.oqtane.org/files/Public/MonsterASPNET.png)](https://www.monsterasp.net/) -# Getting Started (Version 6.1.2) +# Getting Started (Version 6) **Installing using source code from the Dev/Master branch:** -- Install **[.NET 9.0.4 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)**. +- Install **[.NET 9.0.5 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)**. - Install the latest edition (v17.12 or higher) of [Visual Studio 2022](https://visualstudio.microsoft.com/downloads) with the **ASP.NET and web development** workload enabled. Oqtane works with ALL editions of Visual Studio from Community to Enterprise. If you wish to use LocalDB for development ( not a requirement as Oqtane supports SQLite, mySQL, and PostgreSQL ) you must also install the **Data storage and processing**. @@ -92,6 +92,9 @@ Connect with other developers, get support, and share ideas by joining the Oqtan # Roadmap This project is open source, and therefore is a work in progress... +[6.1.3](https://github.com/oqtane/oqtane.framework/releases/tag/v6.1.3) (May 29, 2025) +- [x] Stabilization improvements + [6.1.2](https://github.com/oqtane/oqtane.framework/releases/tag/v6.1.2) (Apr 10, 2025) - [x] Stabilization improvements diff --git a/azuredeploy.json b/azuredeploy.json index e57cc1f8..d7970211 100644 --- a/azuredeploy.json +++ b/azuredeploy.json @@ -220,7 +220,7 @@ "apiVersion": "2024-04-01", "name": "[concat(parameters('BlazorWebsiteName'), '/ZipDeploy')]", "properties": { - "packageUri": "https://github.com/oqtane/oqtane.framework/releases/download/v6.1.2/Oqtane.Framework.6.1.2.Install.zip" + "packageUri": "https://github.com/oqtane/oqtane.framework/releases/download/v6.1.3/Oqtane.Framework.6.1.3.Install.zip" }, "dependsOn": [ "[resourceId('Microsoft.Web/sites', parameters('BlazorWebsiteName'))]"