diff --git a/VueApp/src/CMS/components/DeleteRestoreButtons.vue b/VueApp/src/CMS/components/DeleteRestoreButtons.vue new file mode 100644 index 000000000..c07ce8dff --- /dev/null +++ b/VueApp/src/CMS/components/DeleteRestoreButtons.vue @@ -0,0 +1,37 @@ + + + diff --git a/VueApp/src/CMS/components/EditButton.vue b/VueApp/src/CMS/components/EditButton.vue new file mode 100644 index 000000000..690f2b724 --- /dev/null +++ b/VueApp/src/CMS/components/EditButton.vue @@ -0,0 +1,26 @@ + + + diff --git a/VueApp/src/CMS/components/ModifiedStamp.vue b/VueApp/src/CMS/components/ModifiedStamp.vue new file mode 100644 index 000000000..bfee1971f --- /dev/null +++ b/VueApp/src/CMS/components/ModifiedStamp.vue @@ -0,0 +1,22 @@ + + + diff --git a/VueApp/src/CMS/components/PermissionChips.vue b/VueApp/src/CMS/components/PermissionChips.vue new file mode 100644 index 000000000..37f64921b --- /dev/null +++ b/VueApp/src/CMS/components/PermissionChips.vue @@ -0,0 +1,49 @@ + + + diff --git a/VueApp/src/CMS/pages/CmsHome.vue b/VueApp/src/CMS/pages/CmsHome.vue index 8711149b9..58c9aa4aa 100644 --- a/VueApp/src/CMS/pages/CmsHome.vue +++ b/VueApp/src/CMS/pages/CmsHome.vue @@ -65,6 +65,16 @@ const sections: Section[] = [ { label: "Audit Log", to: { name: "CmsFileAudit" } }, ], }, + { + title: "Content Blocks", + icon: "article", + description: "Edit page content with version history, permissions, and attached files.", + permission: "SVMSecure.CMS.ManageContentBlocks", + actions: [ + { label: "Manage Content Blocks", to: { name: "CmsContentBlocks" } }, + { label: "New Content Block", to: { name: "CmsContentBlockEdit" } }, + ], + }, { title: "Link Collections", icon: "link", @@ -72,6 +82,13 @@ const sections: Section[] = [ permission: "SVMSecure.CMS.ManageContentBlocks", actions: [{ label: "Manage Link Collections", to: { name: "ManageLinkCollections" } }], }, + { + title: "Left Navigation", + icon: "menu", + description: "Build left-nav menus with headers, links, ordering, and per-item visibility.", + permission: "SVMSecure.CMS.ManageNavigation", + actions: [{ label: "Manage Left Nav Menus", to: { name: "CmsLeftNavMenus" } }], + }, ] const visibleSections = computed(() => sections.filter((s) => userStore.userInfo.permissions.includes(s.permission))) diff --git a/VueApp/src/CMS/pages/ContentBlockEdit.vue b/VueApp/src/CMS/pages/ContentBlockEdit.vue new file mode 100644 index 000000000..271f97715 --- /dev/null +++ b/VueApp/src/CMS/pages/ContentBlockEdit.vue @@ -0,0 +1,460 @@ + + + + + diff --git a/VueApp/src/CMS/pages/ContentBlocks.vue b/VueApp/src/CMS/pages/ContentBlocks.vue new file mode 100644 index 000000000..d0b0f1d80 --- /dev/null +++ b/VueApp/src/CMS/pages/ContentBlocks.vue @@ -0,0 +1,231 @@ + + + diff --git a/VueApp/src/CMS/pages/Files.vue b/VueApp/src/CMS/pages/Files.vue index 95c4f47cd..a4fbefd3f 100644 --- a/VueApp/src/CMS/pages/Files.vue +++ b/VueApp/src/CMS/pages/Files.vue @@ -122,42 +122,10 @@ @@ -174,26 +142,15 @@ @@ -250,6 +187,10 @@ import { inject, onMounted, ref } from "vue" import { useQuasar, type QTableProps } from "quasar" import { useFetch } from "@/composables/ViperFetch" import FileFormDialog from "@/CMS/components/FileFormDialog.vue" +import DeleteRestoreButtons from "@/CMS/components/DeleteRestoreButtons.vue" +import EditButton from "@/CMS/components/EditButton.vue" +import ModifiedStamp from "@/CMS/components/ModifiedStamp.vue" +import PermissionChips from "@/CMS/components/PermissionChips.vue" import type { CmsFile } from "@/CMS/types/" const apiURL = inject("apiURL") + "cms/files/" diff --git a/VueApp/src/CMS/pages/LeftNavEdit.vue b/VueApp/src/CMS/pages/LeftNavEdit.vue new file mode 100644 index 000000000..d9ee8de17 --- /dev/null +++ b/VueApp/src/CMS/pages/LeftNavEdit.vue @@ -0,0 +1,444 @@ + + + + + diff --git a/VueApp/src/CMS/pages/LeftNavMenus.vue b/VueApp/src/CMS/pages/LeftNavMenus.vue new file mode 100644 index 000000000..6edf5ea41 --- /dev/null +++ b/VueApp/src/CMS/pages/LeftNavMenus.vue @@ -0,0 +1,162 @@ + + + diff --git a/VueApp/src/CMS/router/routes.ts b/VueApp/src/CMS/router/routes.ts index 92358df0a..af1691deb 100644 --- a/VueApp/src/CMS/router/routes.ts +++ b/VueApp/src/CMS/router/routes.ts @@ -33,6 +33,30 @@ const routes = [ meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.AllFiles"] }, component: () => import("@/CMS/pages/FileAuditLog.vue"), }, + { + path: "/CMS/ManageContentBlocks", + name: "CmsContentBlocks", + meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.ManageContentBlocks"] }, + component: () => import("@/CMS/pages/ContentBlocks.vue"), + }, + { + path: "/CMS/ManageContentBlocks/Edit/:id?", + name: "CmsContentBlockEdit", + meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.ManageContentBlocks"] }, + component: () => import("@/CMS/pages/ContentBlockEdit.vue"), + }, + { + path: "/CMS/ManageLeftNav", + name: "CmsLeftNavMenus", + meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.ManageNavigation"] }, + component: () => import("@/CMS/pages/LeftNavMenus.vue"), + }, + { + path: "/CMS/ManageLeftNav/Edit/:id?", + name: "CmsLeftNavEdit", + meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.ManageNavigation"] }, + component: () => import("@/CMS/pages/LeftNavEdit.vue"), + }, { path: "/:catchAll(.*)*", meta: { layout: ViperLayout }, diff --git a/VueApp/src/CMS/types/index.ts b/VueApp/src/CMS/types/index.ts index 1e12be3b8..48cf8746a 100644 --- a/VueApp/src/CMS/types/index.ts +++ b/VueApp/src/CMS/types/index.ts @@ -34,6 +34,57 @@ type CmsPersonOption = { mailId: string | null } +type CmsContentBlock = { + contentBlockId: number + content: string + title: string | null + system: string + application: string | null + page: string | null + viperSectionPath: string | null + blockOrder: number | null + friendlyName: string | null + allowPublicAccess: boolean + modifiedOn: string + modifiedBy: string + deletedOn: string | null + permissions: string[] + files: CmsContentBlockFile[] +} + +type CmsContentBlockFile = { + fileGuid: string + friendlyName: string + url: string +} + +type CmsContentHistoryItem = { + contentHistoryId: number + modifiedOn: string | null + modifiedBy: string | null +} + +type CmsLeftNavMenu = { + leftNavMenuId: number + menuHeaderText: string | null + system: string + viperSectionPath: string | null + page: string | null + friendlyName: string | null + modifiedOn: string + modifiedBy: string + items: CmsLeftNavItem[] +} + +type CmsLeftNavItem = { + leftNavItemId: number + menuItemText: string | null + isHeader: boolean + url: string | null + displayOrder: number | null + permissions: string[] +} + type CmsFileAudit = { auditId: number timestamp: string @@ -94,6 +145,10 @@ type LinkWithTags = { export type { ContentBlock, + CmsContentBlock, + CmsContentBlockFile, + CmsContentHistoryItem, + CmsLeftNavMenu, CmsFile, CmsFilePerson, CmsPersonOption, diff --git a/test/CMS/CmsContentBlockServiceTests.cs b/test/CMS/CmsContentBlockServiceTests.cs new file mode 100644 index 000000000..3c5c2620e --- /dev/null +++ b/test/CMS/CmsContentBlockServiceTests.cs @@ -0,0 +1,326 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.CMS.Models; +using Viper.Areas.CMS.Services; +using Viper.Classes.SQLContext; +using Viper.Models.VIPER; +using Viper.Services; + +namespace Viper.test.CMS; + +/// +/// Tests for CmsContentBlockService: filtering, create/update with permission and file deltas, +/// history semantics (previous version is stored, stamped with its original author/time), +/// concurrency conflicts, soft delete/restore, and permanent delete cascade. +/// +public sealed class CmsContentBlockServiceTests : IDisposable +{ + private readonly VIPERContext _context; + private readonly IHtmlSanitizerService _sanitizer; + private readonly IUserHelper _userHelper; + private readonly CmsContentBlockService _service; + + public CmsContentBlockServiceTests() + { + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + _sanitizer = Substitute.For(); + _sanitizer.Sanitize(Arg.Any()).Returns(callInfo => callInfo.ArgAt(0)); + _userHelper = Substitute.For(); + + _service = new CmsContentBlockService(_context, _sanitizer, _userHelper); + } + + public void Dispose() + { + _context.Dispose(); + } + + private async Task SeedBlockAsync(Action? customize = null) + { + var block = new ContentBlock + { + Content = "

original

", + Title = "Seeded Block", + System = "Viper", + ViperSectionPath = "cats", + Page = "home", + BlockOrder = 1, + FriendlyName = "seeded-block-" + Guid.NewGuid().ToString("N")[..8], + ModifiedOn = DateTime.Now.AddDays(-2), + ModifiedBy = "originalAuthor" + }; + customize?.Invoke(block); + _context.ContentBlocks.Add(block); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return block; + } + + private static CMSBlockAddEdit MakeRequest(ContentBlock block, Action? customize = null) + { + var request = new CMSBlockAddEdit + { + ContentBlockId = block.ContentBlockId, + Content = block.Content, + Title = block.Title, + System = block.System, + Page = block.Page, + ViperSectionPath = block.ViperSectionPath, + BlockOrder = block.BlockOrder, + FriendlyName = block.FriendlyName, + AllowPublicAccess = block.AllowPublicAccess, + LastModifiedOn = block.ModifiedOn + }; + customize?.Invoke(request); + return request; + } + + #region List / Get + + [Fact] + public async Task GetContentBlocks_FiltersByStatus() + { + await SeedBlockAsync(); + await SeedBlockAsync(b => b.DeletedOn = DateTime.Now); + + var active = await _service.GetContentBlocksAsync("active", null, null, null, TestContext.Current.CancellationToken); + var deleted = await _service.GetContentBlocksAsync("deleted", null, null, null, TestContext.Current.CancellationToken); + var all = await _service.GetContentBlocksAsync("all", null, null, null, TestContext.Current.CancellationToken); + + Assert.Single(active); + Assert.Single(deleted); + Assert.Equal(2, all.Count); + } + + [Fact] + public async Task GetContentBlocks_ListOmitsContent() + { + await SeedBlockAsync(); + + var list = await _service.GetContentBlocksAsync("active", null, null, null, TestContext.Current.CancellationToken); + + Assert.Equal(string.Empty, list[0].Content); + } + + [Fact] + public async Task GetContentBlock_ReturnsContentAndRelations() + { + var block = await SeedBlockAsync(b => + b.ContentBlockToPermissions.Add(new ContentBlockToPermission { Permission = "SVMSecure.CATS" })); + + var dto = await _service.GetContentBlockAsync(block.ContentBlockId, TestContext.Current.CancellationToken); + + Assert.NotNull(dto); + Assert.Equal("

original

", dto!.Content); + Assert.Equal(new List { "SVMSecure.CATS" }, dto.Permissions); + } + + #endregion + + #region Create + + [Fact] + public async Task Create_SavesBlockWithPermissionsAndNoHistory() + { + var request = new CMSBlockAddEdit + { + ContentBlockId = 0, + Content = "

new

", + Title = "New Block", + System = "Viper", + AllowPublicAccess = false, + Permissions = new List { "SVMSecure.CATS" } + }; + + var dto = await _service.CreateContentBlockAsync(request, TestContext.Current.CancellationToken); + + Assert.True(dto.ContentBlockId > 0); + Assert.Equal(new List { "SVMSecure.CATS" }, dto.Permissions); + // Legacy semantics: history holds previous versions only, so a new block has none. + Assert.Empty(_context.ContentHistories.Where(h => h.ContentBlockId == dto.ContentBlockId)); + } + + [Fact] + public async Task Create_DuplicateFriendlyName_Throws() + { + var existing = await SeedBlockAsync(); + var request = new CMSBlockAddEdit + { + ContentBlockId = 0, + Content = "x", + Title = "Dup", + System = "Viper", + AllowPublicAccess = false, + FriendlyName = existing.FriendlyName + }; + + await Assert.ThrowsAsync( + () => _service.CreateContentBlockAsync(request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Create_UnknownFileGuid_Throws() + { + var request = new CMSBlockAddEdit + { + ContentBlockId = 0, + Content = "x", + Title = "Has Bad File", + System = "Viper", + AllowPublicAccess = false, + FileGuids = new List { Guid.NewGuid() } + }; + + await Assert.ThrowsAsync( + () => _service.CreateContentBlockAsync(request, TestContext.Current.CancellationToken)); + } + + #endregion + + #region Update + + [Fact] + public async Task Update_WritesPreviousVersionToHistory() + { + var block = await SeedBlockAsync(); + var originalModifiedOn = block.ModifiedOn; + var request = MakeRequest(block, r => r.Content = "

updated

"); + + var dto = await _service.UpdateContentBlockAsync(block.ContentBlockId, request, TestContext.Current.CancellationToken); + + Assert.Equal("

updated

", dto!.Content); + var history = Assert.Single(_context.ContentHistories.Where(h => h.ContentBlockId == block.ContentBlockId)); + Assert.Equal("

original

", history.ContentBlockContent); + Assert.Equal("originalAuthor", history.ModifiedBy); + Assert.Equal(originalModifiedOn, history.ModifiedOn); + } + + [Fact] + public async Task Update_StaleLastModifiedOn_ThrowsConcurrency() + { + var block = await SeedBlockAsync(); + var request = MakeRequest(block, r => r.LastModifiedOn = block.ModifiedOn.AddMinutes(-5)); + + await Assert.ThrowsAsync( + () => _service.UpdateContentBlockAsync(block.ContentBlockId, request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Update_NullLastModifiedOn_Throws() + { + var block = await SeedBlockAsync(); + var request = MakeRequest(block, r => r.LastModifiedOn = null); + + await Assert.ThrowsAsync( + () => _service.UpdateContentBlockAsync(block.ContentBlockId, request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Update_UnknownFileGuid_Throws() + { + var block = await SeedBlockAsync(); + var request = MakeRequest(block, r => r.FileGuids = new List { Guid.NewGuid() }); + + await Assert.ThrowsAsync( + () => _service.UpdateContentBlockAsync(block.ContentBlockId, request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Update_AppliesPermissionAndFileDeltas() + { + var file = new Viper.Models.VIPER.File + { + FileGuid = Guid.NewGuid(), + FilePath = @"C:\FakeRoot\cats\attach.pdf", + Folder = "cats", + FriendlyName = "cats-attach.pdf", + Description = "", + ModifiedBy = "test", + ModifiedOn = DateTime.Now + }; + _context.Files.Add(file); + var block = await SeedBlockAsync(b => + b.ContentBlockToPermissions.Add(new ContentBlockToPermission { Permission = "SVMSecure.Old" })); + + var request = MakeRequest(block, r => + { + r.Permissions = new List { "SVMSecure.New" }; + r.FileGuids = new List { file.FileGuid }; + }); + + var dto = await _service.UpdateContentBlockAsync(block.ContentBlockId, request, TestContext.Current.CancellationToken); + + Assert.Equal(new List { "SVMSecure.New" }, dto!.Permissions); + Assert.Single(dto.Files); + Assert.Equal("cats-attach.pdf", dto.Files[0].FriendlyName); + } + + [Fact] + public async Task UpdateContentOnly_PreservesOtherFieldsAndWritesHistory() + { + var block = await SeedBlockAsync(); + + var dto = await _service.UpdateContentOnlyAsync(block.ContentBlockId, "

quick edit

", block.ModifiedOn, + TestContext.Current.CancellationToken); + + Assert.Equal("

quick edit

", dto!.Content); + Assert.Equal("Seeded Block", dto.Title); + var history = Assert.Single(_context.ContentHistories.Where(h => h.ContentBlockId == block.ContentBlockId)); + Assert.Equal("

original

", history.ContentBlockContent); + } + + #endregion + + #region Delete / Restore / History + + [Fact] + public async Task SoftDeleteAndRestore_ToggleDeletedOn() + { + var block = await SeedBlockAsync(); + + Assert.True(await _service.SoftDeleteAsync(block.ContentBlockId, TestContext.Current.CancellationToken)); + Assert.NotNull((await _context.ContentBlocks.SingleAsync(TestContext.Current.CancellationToken)).DeletedOn); + + Assert.True(await _service.RestoreAsync(block.ContentBlockId, TestContext.Current.CancellationToken)); + Assert.Null((await _context.ContentBlocks.SingleAsync(TestContext.Current.CancellationToken)).DeletedOn); + } + + [Fact] + public async Task PermanentDelete_RemovesBlockAndChildren() + { + var block = await SeedBlockAsync(b => + { + b.ContentBlockToPermissions.Add(new ContentBlockToPermission { Permission = "SVMSecure" }); + b.ContentHistories.Add(new ContentHistory + { + ContentBlockContent = "old", + ModifiedOn = DateTime.Now.AddDays(-3), + ModifiedBy = "x" + }); + }); + + var result = await _service.PermanentlyDeleteAsync(block.ContentBlockId, TestContext.Current.CancellationToken); + + Assert.True(result); + Assert.Empty(await _context.ContentBlocks.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.ContentHistories.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.ContentBlockToPermissions.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task History_ListAndVersionRetrieval() + { + var block = await SeedBlockAsync(); + await _service.UpdateContentOnlyAsync(block.ContentBlockId, "

v2

", block.ModifiedOn, TestContext.Current.CancellationToken); + await _service.UpdateContentOnlyAsync(block.ContentBlockId, "

v3

", block.ModifiedOn, TestContext.Current.CancellationToken); + + var history = await _service.GetHistoryAsync(block.ContentBlockId, TestContext.Current.CancellationToken); + Assert.Equal(2, history.Count); + + var oldest = await _service.GetHistoryVersionAsync(block.ContentBlockId, history[^1].ContentHistoryId, + TestContext.Current.CancellationToken); + Assert.Equal("

original

", oldest!.Content); + } + + #endregion +} diff --git a/test/CMS/CmsLeftNavServiceTests.cs b/test/CMS/CmsLeftNavServiceTests.cs new file mode 100644 index 000000000..f3e04d87c --- /dev/null +++ b/test/CMS/CmsLeftNavServiceTests.cs @@ -0,0 +1,181 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; +using Viper.Classes.SQLContext; +using Viper.Models.VIPER; + +namespace Viper.test.CMS; + +/// +/// Tests for CmsLeftNavService: menu CRUD with cascade delete and the batch item save +/// (add/update/delete/reorder in one call, replacing the legacy DataTables editor). +/// +public sealed class CmsLeftNavServiceTests : IDisposable +{ + private readonly VIPERContext _context; + private readonly CmsLeftNavService _service; + + public CmsLeftNavServiceTests() + { + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + _service = new CmsLeftNavService(_context, Substitute.For()); + } + + public void Dispose() + { + _context.Dispose(); + } + + private async Task SeedMenuAsync(Action? customize = null) + { + var menu = new LeftNavMenu + { + MenuHeaderText = "Seeded Menu", + System = "Viper", + ViperSectionPath = "cats", + FriendlyName = "seeded-menu-" + Guid.NewGuid().ToString("N")[..8], + ModifiedOn = DateTime.Now.AddDays(-1), + ModifiedBy = "test" + }; + customize?.Invoke(menu); + _context.LeftNavMenus.Add(menu); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return menu; + } + + private static LeftNavItem MakeItem(string text, int order, bool isHeader = false, string? url = null, params string[] permissions) + { + var item = new LeftNavItem + { + MenuItemText = text, + IsHeader = isHeader, + Url = url, + DisplayOrder = order + }; + foreach (var p in permissions) + { + item.LeftNavItemToPermissions.Add(new LeftNavItemToPermission { Permission = p }); + } + return item; + } + + [Fact] + public async Task GetMenus_FiltersBySystemAndSearch() + { + await SeedMenuAsync(); + await SeedMenuAsync(m => + { + m.MenuHeaderText = "Public Menu"; + m.System = "Public"; + }); + + var viperMenus = await _service.GetMenusAsync("Viper", null, null, TestContext.Current.CancellationToken); + var searched = await _service.GetMenusAsync(null, null, "Public", TestContext.Current.CancellationToken); + + Assert.Single(viperMenus); + Assert.Single(searched); + Assert.Equal("Public Menu", searched[0].MenuHeaderText); + } + + [Fact] + public async Task GetMenu_ReturnsItemsInDisplayOrder() + { + var menu = await SeedMenuAsync(m => + { + m.LeftNavItems.Add(MakeItem("Second", 2, url: "/two")); + m.LeftNavItems.Add(MakeItem("First", 1, isHeader: true)); + }); + + var dto = await _service.GetMenuAsync(menu.LeftNavMenuId, TestContext.Current.CancellationToken); + + Assert.Equal(new[] { "First", "Second" }, dto!.Items.Select(i => i.MenuItemText).ToArray()); + Assert.True(dto.Items[0].IsHeader); + } + + [Fact] + public async Task CreateAndUpdateMenu_PersistFields() + { + var created = await _service.CreateMenuAsync(new LeftNavMenuAddEdit + { + MenuHeaderText = "New Menu", + System = "Viper", + ViperSectionPath = "students", + Page = "home", + FriendlyName = "new-menu" + }, TestContext.Current.CancellationToken); + + Assert.True(created.LeftNavMenuId > 0); + + var updated = await _service.UpdateMenuAsync(created.LeftNavMenuId, new LeftNavMenuAddEdit + { + MenuHeaderText = "Renamed Menu", + System = "Viper" + }, TestContext.Current.CancellationToken); + + Assert.Equal("Renamed Menu", updated!.MenuHeaderText); + Assert.Null(updated.ViperSectionPath); + } + + [Fact] + public async Task DeleteMenu_CascadesToItemsAndPermissions() + { + var menu = await SeedMenuAsync(m => m.LeftNavItems.Add(MakeItem("Link", 1, url: "/x", permissions: "SVMSecure.CATS"))); + + var result = await _service.DeleteMenuAsync(menu.LeftNavMenuId, TestContext.Current.CancellationToken); + + Assert.True(result); + Assert.Empty(await _context.LeftNavMenus.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.LeftNavItems.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.LeftNavItemToPermissions.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveItems_AddsUpdatesDeletesAndReorders() + { + var menu = await SeedMenuAsync(m => + { + m.LeftNavItems.Add(MakeItem("Keep Me", 1, url: "/keep", permissions: "SVMSecure.Old")); + m.LeftNavItems.Add(MakeItem("Delete Me", 2, url: "/delete")); + }); + var keepId = menu.LeftNavItems.First(i => i.MenuItemText == "Keep Me").LeftNavItemId; + + var dto = await _service.SaveItemsAsync(menu.LeftNavMenuId, new List + { + new() { LeftNavItemId = 0, MenuItemText = "New Header", IsHeader = true }, + new() { LeftNavItemId = keepId, MenuItemText = "Keep Me Renamed", Url = "/kept", Permissions = new List { "SVMSecure.New" } }, + }, TestContext.Current.CancellationToken); + + Assert.Equal(2, dto!.Items.Count); + Assert.Equal("New Header", dto.Items[0].MenuItemText); + Assert.Equal(1, dto.Items[0].DisplayOrder); + Assert.True(dto.Items[0].IsHeader); + Assert.Equal("Keep Me Renamed", dto.Items[1].MenuItemText); + Assert.Equal(2, dto.Items[1].DisplayOrder); + Assert.Equal(new List { "SVMSecure.New" }, dto.Items[1].Permissions); + // Deleted item's permissions are gone too + Assert.Equal(1, await _context.LeftNavItemToPermissions.CountAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task SaveItems_HeaderItems_DropUrl() + { + var menu = await SeedMenuAsync(); + + var dto = await _service.SaveItemsAsync(menu.LeftNavMenuId, new List + { + new() { LeftNavItemId = 0, MenuItemText = "Header", IsHeader = true, Url = "/should-be-dropped" }, + }, TestContext.Current.CancellationToken); + + Assert.Null(dto!.Items[0].Url); + } + + [Fact] + public async Task SaveItems_UnknownMenu_ReturnsNull() + { + var dto = await _service.SaveItemsAsync(9999, new List(), TestContext.Current.CancellationToken); + + Assert.Null(dto); + } +} diff --git a/test/CMS/SafeUrlAttributeTests.cs b/test/CMS/SafeUrlAttributeTests.cs new file mode 100644 index 000000000..0cdd5eb4a --- /dev/null +++ b/test/CMS/SafeUrlAttributeTests.cs @@ -0,0 +1,70 @@ +using System.ComponentModel.DataAnnotations; +using Areas.CMS.Validation; + +namespace Viper.test.CMS; + +/// +/// Tests for SafeUrlAttribute, in both absolute-only mode (link collections) and +/// AllowRelative mode (left nav item URLs). +/// +public sealed class SafeUrlAttributeTests +{ + private static ValidationResult? Validate(string? url, bool allowRelative) + { + var attribute = new SafeUrlAttribute { AllowRelative = allowRelative }; + return attribute.GetValidationResult(url, new ValidationContext(new object())); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("https://example.com/page")] + [InlineData("http://example.com")] + [InlineData("mailto:someone@example.com")] + [InlineData("tel:+15305551234")] + public void AbsoluteMode_AllowsSafeSchemesAndEmpty(string? url) + { + Assert.Equal(ValidationResult.Success, Validate(url, allowRelative: false)); + } + + [Theory] + [InlineData("javascript:alert(document.cookie)")] + [InlineData("JAVASCRIPT:alert(1)")] + [InlineData("data:text/html,")] + [InlineData("vbscript:msgbox(1)")] + [InlineData("java\tscript:alert(1)")] + [InlineData("java\nscript:alert(1)")] + public void BothModes_RejectDangerousUrls(string url) + { + Assert.NotEqual(ValidationResult.Success, Validate(url, allowRelative: false)); + Assert.NotEqual(ValidationResult.Success, Validate(url, allowRelative: true)); + } + + [Theory] + [InlineData("/CMS/files")] + [InlineData("~/ManageLeftNav")] + [InlineData("page.cfm?x=1")] + [InlineData("path/to/page#section")] + public void RelativeMode_AllowsRelativeUrls(string url) + { + Assert.Equal(ValidationResult.Success, Validate(url, allowRelative: true)); + } + + [Fact] + public void AbsoluteMode_RejectsRelativeUrls() + { + Assert.NotEqual(ValidationResult.Success, Validate("/CMS/files", allowRelative: false)); + } + + [Fact] + public void RelativeMode_RejectsColonInFirstSegment() + { + Assert.NotEqual(ValidationResult.Success, Validate("foo:bar/baz", allowRelative: true)); + } + + [Fact] + public void RelativeMode_AllowsColonAfterFirstSegment() + { + Assert.Equal(ValidationResult.Success, Validate("path/page:1", allowRelative: true)); + } +} diff --git a/web/Areas/CMS/Controllers/CMSContentController.cs b/web/Areas/CMS/Controllers/CMSContentController.cs index 8b0399f31..e83e4bfed 100644 --- a/web/Areas/CMS/Controllers/CMSContentController.cs +++ b/web/Areas/CMS/Controllers/CMSContentController.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Constants; using Viper.Areas.CMS.Models; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; using Viper.Classes; using Viper.Classes.SQLContext; using Viper.Models.VIPER; @@ -16,33 +18,60 @@ public class CMSContentController : ApiController private readonly VIPERContext _context; private readonly RAPSContext _rapsContext; private readonly IHtmlSanitizerService _sanitizerService; - public IUserHelper UserHelper { get; private set; } + private readonly ICmsContentBlockService _blockService; + private readonly IUserHelper _userHelper; - public CMSContentController(VIPERContext context, RAPSContext rapsContext, IHtmlSanitizerService sanitizerService) + public CMSContentController(VIPERContext context, RAPSContext rapsContext, + IHtmlSanitizerService sanitizerService, ICmsContentBlockService blockService, IUserHelper userHelper) { _context = context; _rapsContext = rapsContext; _sanitizerService = sanitizerService; - UserHelper = new UserHelper(); + _blockService = blockService; + _userHelper = userHelper; } //GET: content [HttpGet] - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] - public ActionResult> GetContentBlocks() + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task>> GetContentBlocks( + string status = "active", + string? system = null, + string? viperSectionPath = null, + string? search = null, + CancellationToken ct = default) { - if (_context.ContentBlocks == null) + return await _blockService.GetContentBlocksAsync(status, system, viperSectionPath, search, ct); + } + + //GET: content/section-paths + [HttpGet("section-paths")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task>> GetViperSectionPaths(CancellationToken ct = default) + { + return await _blockService.GetViperSectionPathsAsync(ct); + } + + //GET: content/5 + [HttpGet("{contentBlockId:int}")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task> GetContentBlock(int contentBlockId, CancellationToken ct = default) + { + var block = await _blockService.GetContentBlockAsync(contentBlockId, ct); + if (block == null) { return NotFound(); } - return new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks()?.ToList() ?? new List(); + return block; } - //GET: content/fn/{friendlyName} + //GET: content/fn/{friendlyName} — public display endpoint; permission filtering happens + //inside GetContentBlocksAllowed (public flag, block permissions, or CMS admin). [HttpGet("fn/{friendlyName}")] public ActionResult GetContentBlockByFn(string friendlyName) { - var blocks = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocksAllowed(null, friendlyName, null, null, null, null, null, null)?.ToList(); + var blocks = new Data.CMS(_context, _rapsContext, _sanitizerService) + .GetContentBlocksAllowed(null, friendlyName, null, null, null, null, null, null)?.ToList(); if (blocks == null || blocks.Count == 0) { return NotFound(); @@ -51,18 +80,55 @@ public ActionResult> GetContentBlocks() return blocks[0]; } - //PUT: content/5 - [HttpPut("{contentBlockId}")] - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] - public async Task> UpdateContentBlock(int contentBlockId, CMSBlockAddEdit block) + //GET: content/5/history + [HttpGet("{contentBlockId:int}/history")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task>> GetHistory(int contentBlockId, CancellationToken ct = default) + { + return await _blockService.GetHistoryAsync(contentBlockId, ct); + } + + //GET: content/5/history/12 + [HttpGet("{contentBlockId:int}/history/{contentHistoryId:int}")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task> GetHistoryVersion(int contentBlockId, int contentHistoryId, + CancellationToken ct = default) { - //check data is valid and block is found - var existingBlock = await _context.ContentBlocks.FindAsync(contentBlockId); - if (existingBlock == null) + var version = await _blockService.GetHistoryVersionAsync(contentBlockId, contentHistoryId, ct); + if (version == null) { return NotFound(); } + return version; + } + + //POST: content + [HttpPost] + [Permission(Allow = CmsPermissions.CreateContentBlock + "," + CmsPermissions.ManageContentBlocks)] + public async Task> CreateContentBlock(CMSBlockAddEdit block, CancellationToken ct = default) + { + string inputCheck = CheckBlockForRequiredFields(block); + if (!string.IsNullOrEmpty(inputCheck)) + { + return BadRequest(inputCheck); + } + try + { + return await _blockService.CreateContentBlockAsync(block, ct); + } + catch (ArgumentException ex) + { + return ValidationProblem(ex.Message); + } + } + + //PUT: content/5 + [HttpPut("{contentBlockId:int}")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task> UpdateContentBlock(int contentBlockId, CMSBlockAddEdit block, + CancellationToken ct = default) + { if (contentBlockId != block.ContentBlockId) { return BadRequest(); @@ -74,92 +140,74 @@ public async Task> UpdateContentBlock(int contentBloc return BadRequest(inputCheck); } - var friendlyNameCheck = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(friendlyName: block.FriendlyName)?.FirstOrDefault(); - if (friendlyNameCheck != null && friendlyNameCheck.ContentBlockId != contentBlockId) + try { - return ValidationProblem("Friendly name must be unique"); + var updated = await _blockService.UpdateContentBlockAsync(contentBlockId, block, ct); + if (updated == null) + { + return NotFound(); + } + return updated; } - - if (friendlyNameCheck != null) + catch (CmsConcurrencyException ex) { - _context.Entry(friendlyNameCheck).State = EntityState.Detached; + return Conflict(ex.Message); } - - //modify database object - ModifyBlockWithUserInput(existingBlock, block); - _context.Entry(existingBlock).State = EntityState.Modified; - - //save history - var contentHistory = new ContentHistory + catch (ArgumentException ex) { - ContentBlockId = contentBlockId, - ContentBlockContent = block.Content, - ModifiedOn = DateTime.Now, - ModifiedBy = UserHelper.GetCurrentUser()?.LoginId - }; - _context.ContentHistories.Add(contentHistory); - - //save and return the saved block - await _context.SaveChangesAsync(); - var returnBlock = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(contentBlockID: contentBlockId)?.FirstOrDefault(); - if (returnBlock == null) - { - return NotFound(); + return ValidationProblem(ex.Message); } - return returnBlock; } - //POST: content - [HttpPost] - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] - public async Task> CreateContentBlock(CMSBlockAddEdit block) + //PATCH: content/5/content — quick save for content-only edits + [HttpPatch("{contentBlockId:int}/content")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task> UpdateContentOnly(int contentBlockId, + ContentOnlyUpdate update, CancellationToken ct = default) { - string inputCheck = CheckBlockForRequiredFields(block); - if (!string.IsNullOrEmpty(inputCheck)) + try { - return BadRequest(inputCheck); + var updated = await _blockService.UpdateContentOnlyAsync(contentBlockId, update.Content, update.LastModifiedOn, ct); + if (updated == null) + { + return NotFound(); + } + return updated; } - var friendlyNameCheck = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(friendlyName: block.FriendlyName)?.FirstOrDefault(); - if (friendlyNameCheck != null) + catch (CmsConcurrencyException ex) { - return ValidationProblem("Friendly name must be unique"); + return Conflict(ex.Message); } - - var newBlock = new ContentBlock(); - ModifyBlockWithUserInput(newBlock, block); - - _context.ContentBlocks.Add(newBlock); - await _context.SaveChangesAsync(); - - var contentHistory = new ContentHistory + catch (ArgumentException ex) { - ContentBlockId = block.ContentBlockId, - ContentBlockContent = block.Content, - ModifiedOn = DateTime.Now, - ModifiedBy = UserHelper.GetCurrentUser()?.LoginId - }; - _context.ContentHistories.Add(contentHistory); - await _context.SaveChangesAsync(); + return ValidationProblem(ex.Message); + } + } - return newBlock; + //POST: content/5/restore + [HttpPost("{contentBlockId:int}/restore")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task RestoreContentBlock(int contentBlockId, CancellationToken ct = default) + { + return await _blockService.RestoreAsync(contentBlockId, ct) ? Ok() : NotFound(); } - //DELETE: content/5 - [HttpDelete("{contentBlockId}")] - [Permission(Allow = "SVMSecure.CMS.ManageContentBlocks")] - public async Task> DeleteContentBlock(int contentBlockId) + //DELETE: content/5?permanent=true|false + [HttpDelete("{contentBlockId:int}")] + [Permission(Allow = CmsPermissions.ManageContentBlocks)] + public async Task DeleteContentBlock(int contentBlockId, bool permanent = false, + CancellationToken ct = default) { - var block = new Data.CMS(_context, _rapsContext, _sanitizerService).GetContentBlocks(contentBlockID: contentBlockId)?.FirstOrDefault(); - if (block == null) + if (permanent) { - return NotFound(); + // Permanent delete removes history and cannot be undone; admin only. + if (!_userHelper.HasPermission(_rapsContext, _userHelper.GetCurrentUser(), CmsPermissions.Admin)) + { + return ForbidApi(); + } + return await _blockService.PermanentlyDeleteAsync(contentBlockId, ct) ? NoContent() : NotFound(); } - - block.DeletedOn = DateTime.Now; - block.ModifiedBy = UserHelper.GetCurrentUser()?.LoginId; - _context.Entry(block).State = EntityState.Modified; - await _context.SaveChangesAsync(); - return block; + return await _blockService.SoftDeleteAsync(contentBlockId, ct) ? NoContent() : NotFound(); } private static string CheckBlockForRequiredFields(CMSBlockAddEdit userInput) @@ -175,39 +223,11 @@ private static string CheckBlockForRequiredFields(CMSBlockAddEdit userInput) } return errors; } + } - private void ModifyBlockWithUserInput(ContentBlock contentBlock, CMSBlockAddEdit userInput) - { - //update info - contentBlock.Title = userInput.Title; - contentBlock.Content = userInput.Content; - contentBlock.FriendlyName = userInput.FriendlyName; - contentBlock.System = userInput.System; - contentBlock.Application = userInput.Application; - contentBlock.Page = userInput.Page; - contentBlock.ViperSectionPath = userInput.ViperSectionPath; - contentBlock.AllowPublicAccess = userInput.AllowPublicAccess; - contentBlock.BlockOrder = userInput.BlockOrder; - contentBlock.ModifiedOn = DateTime.Now; - contentBlock.ModifiedBy = UserHelper.GetCurrentUser()?.LoginId; - - //adjust permissions - //remove content block permisisons that are not in the user input - foreach (var cbp in contentBlock.ContentBlockToPermissions.Where(cbp => !userInput.Permissions.Contains(cbp.Permission))) - { - contentBlock.ContentBlockToPermissions.Remove(cbp); - } - - //add new content block permissions, if they are not in the existing list - var existingPermissions = contentBlock.ContentBlockToPermissions.Select(p => p.Permission).ToList(); - foreach (var p in userInput.Permissions.Where(p => !existingPermissions.Contains(p))) - { - contentBlock.ContentBlockToPermissions.Add(new ContentBlockToPermission - { - Permission = p, - ContentBlockId = userInput.ContentBlockId - }); - } - } + public class ContentOnlyUpdate + { + public string Content { get; set; } = string.Empty; + public DateTime? LastModifiedOn { get; set; } } } diff --git a/web/Areas/CMS/Controllers/CMSLeftNavController.cs b/web/Areas/CMS/Controllers/CMSLeftNavController.cs new file mode 100644 index 000000000..dbafbda85 --- /dev/null +++ b/web/Areas/CMS/Controllers/CMSLeftNavController.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.CMS.Controllers +{ + /// + /// Left navigation menu management. Display of menus to end users goes through + /// LayoutController / Data.LeftNavMenu, which filters items by user permission. + /// + [Route("/api/cms/left-navs")] + [Permission(Allow = CmsPermissions.ManageNavigation)] + public class CMSLeftNavController : ApiController + { + private readonly ICmsLeftNavService _leftNavService; + + public CMSLeftNavController(ICmsLeftNavService leftNavService) + { + _leftNavService = leftNavService; + } + + // GET /api/cms/left-navs + [HttpGet] + public async Task>> GetMenus( + string? system, string? viperSectionPath, string? search, CancellationToken ct = default) + { + return await _leftNavService.GetMenusAsync(system, viperSectionPath, search, ct); + } + + // GET /api/cms/left-navs/5 + [HttpGet("{leftNavMenuId:int}")] + public async Task> GetMenu(int leftNavMenuId, CancellationToken ct = default) + { + var menu = await _leftNavService.GetMenuAsync(leftNavMenuId, ct); + if (menu == null) + { + return NotFound(); + } + return menu; + } + + // POST /api/cms/left-navs + [HttpPost] + public async Task> CreateMenu(LeftNavMenuAddEdit menu, CancellationToken ct = default) + { + var created = await _leftNavService.CreateMenuAsync(menu, ct); + return CreatedAtAction(nameof(GetMenu), new { leftNavMenuId = created.LeftNavMenuId }, created); + } + + // PUT /api/cms/left-navs/5 + [HttpPut("{leftNavMenuId:int}")] + public async Task> UpdateMenu(int leftNavMenuId, LeftNavMenuAddEdit menu, + CancellationToken ct = default) + { + var updated = await _leftNavService.UpdateMenuAsync(leftNavMenuId, menu, ct); + if (updated == null) + { + return NotFound(); + } + return updated; + } + + // PUT /api/cms/left-navs/5/items — full item list; order follows the array + [HttpPut("{leftNavMenuId:int}/items")] + public async Task> SaveItems(int leftNavMenuId, List items, + CancellationToken ct = default) + { + if (items.Where(i => i.LeftNavItemId > 0).GroupBy(i => i.LeftNavItemId).Any(g => g.Count() > 1)) + { + return BadRequest("Duplicate item ids are not allowed."); + } + + var updated = await _leftNavService.SaveItemsAsync(leftNavMenuId, items, ct); + if (updated == null) + { + return NotFound(); + } + return updated; + } + + // DELETE /api/cms/left-navs/5 — deletes the menu and all items (no soft delete, matching legacy) + [HttpDelete("{leftNavMenuId:int}")] + public async Task DeleteMenu(int leftNavMenuId, CancellationToken ct = default) + { + return await _leftNavService.DeleteMenuAsync(leftNavMenuId, ct) ? NoContent() : NotFound(); + } + } +} diff --git a/web/Areas/CMS/Data/LeftNavMenu.cs b/web/Areas/CMS/Data/LeftNavMenu.cs index 6f9d83387..3273645f1 100644 --- a/web/Areas/CMS/Data/LeftNavMenu.cs +++ b/web/Areas/CMS/Data/LeftNavMenu.cs @@ -50,9 +50,11 @@ public LeftNavMenu(VIPERContext viperContext, RAPSContext rapsContext) List cmsMenus = new(); foreach (var m in menus) { - //by default, filter items based on user permissions + //by default, filter items based on user permissions; items with no + //permissions are visible to any logged-in user (legacy CMS behavior) List items = m.LeftNavItems .Where(item => !filterItemsByPermissions + || item.LeftNavItemToPermissions.Count == 0 || item.LeftNavItemToPermissions.Any(p => UserHelper.HasPermission(_rapsContext, currentUser, p.Permission))) .Select(item => new NavMenuItem(item)) .ToList(); diff --git a/web/Areas/CMS/Models/CMSBlockAddEdit.cs b/web/Areas/CMS/Models/CMSBlockAddEdit.cs index c1ba7dafe..fa9bac352 100644 --- a/web/Areas/CMS/Models/CMSBlockAddEdit.cs +++ b/web/Areas/CMS/Models/CMSBlockAddEdit.cs @@ -23,5 +23,13 @@ public class CMSBlockAddEdit public required bool AllowPublicAccess { get; set; } public ICollection Permissions { get; set; } = new List(); + + public List FileGuids { get; set; } = new(); + + /// + /// ModifiedOn value the client loaded; updates with a stale value get 409 Conflict. + /// Required for updates (null gets 400); ignored on create. + /// + public DateTime? LastModifiedOn { get; set; } } } diff --git a/web/Areas/CMS/Models/CmsContentBlockMapper.cs b/web/Areas/CMS/Models/CmsContentBlockMapper.cs new file mode 100644 index 000000000..2c128a772 --- /dev/null +++ b/web/Areas/CMS/Models/CmsContentBlockMapper.cs @@ -0,0 +1,31 @@ +using Riok.Mapperly.Abstractions; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Models.VIPER; + +namespace Viper.Areas.CMS.Models +{ + [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] + public static partial class CmsContentBlockMapper + { + public static ContentBlockDto ToDto(ContentBlock block) + { + var dto = ToDtoBase(block); + dto.Permissions = block.ContentBlockToPermissions.Select(p => p.Permission).OrderBy(p => p).ToList(); + dto.Files = block.ContentBlockToFiles + .Where(f => f.File != null) + .Select(f => new ContentBlockFileDto + { + FileGuid = f.FileGuid, + FriendlyName = f.File.FriendlyName, + Url = Data.CMS.GetFriendlyURL(f.File.FriendlyName, f.File.AllowPublicAccess) + }) + .OrderBy(f => f.FriendlyName) + .ToList(); + return dto; + } + + [MapperIgnoreTarget(nameof(ContentBlockDto.Permissions))] + [MapperIgnoreTarget(nameof(ContentBlockDto.Files))] + private static partial ContentBlockDto ToDtoBase(ContentBlock block); + } +} diff --git a/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs b/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs new file mode 100644 index 000000000..c48e6fec1 --- /dev/null +++ b/web/Areas/CMS/Models/DTOs/ContentBlockDto.cs @@ -0,0 +1,40 @@ +namespace Viper.Areas.CMS.Models.DTOs +{ + public class ContentBlockDto + { + public int ContentBlockId { get; set; } + public string Content { get; set; } = string.Empty; + public string? Title { get; set; } + public string System { get; set; } = string.Empty; + public string? Application { get; set; } + public string? Page { get; set; } + public string? ViperSectionPath { get; set; } + public int? BlockOrder { get; set; } + public string? FriendlyName { get; set; } + public bool AllowPublicAccess { get; set; } + public DateTime ModifiedOn { get; set; } + public string ModifiedBy { get; set; } = string.Empty; + public DateTime? DeletedOn { get; set; } + public List Permissions { get; set; } = new(); + public List Files { get; set; } = new(); + } + + public class ContentBlockFileDto + { + public Guid FileGuid { get; set; } + public string FriendlyName { get; set; } = string.Empty; + public string Url { get; set; } = string.Empty; + } + + public class ContentHistoryListItemDto + { + public int ContentHistoryId { get; set; } + public DateTime? ModifiedOn { get; set; } + public string? ModifiedBy { get; set; } + } + + public class ContentHistoryDto : ContentHistoryListItemDto + { + public string Content { get; set; } = string.Empty; + } +} diff --git a/web/Areas/CMS/Models/DTOs/LeftNavDtos.cs b/web/Areas/CMS/Models/DTOs/LeftNavDtos.cs new file mode 100644 index 000000000..aa85fcde5 --- /dev/null +++ b/web/Areas/CMS/Models/DTOs/LeftNavDtos.cs @@ -0,0 +1,69 @@ +using System.ComponentModel.DataAnnotations; +using Areas.CMS.Validation; + +namespace Viper.Areas.CMS.Models.DTOs +{ + public class LeftNavMenuDto + { + public int LeftNavMenuId { get; set; } + public string? MenuHeaderText { get; set; } + public string System { get; set; } = string.Empty; + public string? ViperSectionPath { get; set; } + public string? Page { get; set; } + public string? FriendlyName { get; set; } + public DateTime ModifiedOn { get; set; } + public string ModifiedBy { get; set; } = string.Empty; + public List Items { get; set; } = new(); + } + + public class LeftNavItemDto + { + public int LeftNavItemId { get; set; } + public string? MenuItemText { get; set; } + public bool IsHeader { get; set; } + public string? Url { get; set; } + public int? DisplayOrder { get; set; } + public List Permissions { get; set; } = new(); + } + + public class LeftNavMenuAddEdit + { + [Required] + [MaxLength(500)] + public string MenuHeaderText { get; set; } = string.Empty; + + [Required] + [MaxLength(50)] + public string System { get; set; } = "Viper"; + + [MaxLength(250)] + public string? ViperSectionPath { get; set; } + + [MaxLength(250)] + public string? Page { get; set; } + + [MaxLength(250)] + public string? FriendlyName { get; set; } + } + + /// + /// Full item list for a menu; saving replaces the menu's items (new items have id 0, + /// omitted ids are deleted, order follows the array). + /// + public class LeftNavItemEdit + { + public int LeftNavItemId { get; set; } + + [Required] + [MaxLength(500)] + public string MenuItemText { get; set; } = string.Empty; + + public bool IsHeader { get; set; } + + [MaxLength(1000)] + [SafeUrl(AllowRelative = true)] + public string? Url { get; set; } + + public List Permissions { get; set; } = new(); + } +} diff --git a/web/Areas/CMS/Services/CmsContentBlockService.cs b/web/Areas/CMS/Services/CmsContentBlockService.cs new file mode 100644 index 000000000..22cc40c0e --- /dev/null +++ b/web/Areas/CMS/Services/CmsContentBlockService.cs @@ -0,0 +1,466 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Models; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Classes.SQLContext; +using Viper.Models.VIPER; +using Viper.Services; + +namespace Viper.Areas.CMS.Services +{ + /// + /// Thrown when an update is based on a stale copy of a block (someone else saved since + /// the editor loaded it). Controllers translate this to 409 Conflict. + /// + public class CmsConcurrencyException : InvalidOperationException + { + public CmsConcurrencyException(string message) : base(message) { } + } + + public interface ICmsContentBlockService + { + Task> GetContentBlocksAsync(string status, string? system, string? viperSectionPath, + string? search, CancellationToken ct = default); + + Task GetContentBlockAsync(int contentBlockId, CancellationToken ct = default); + + Task CreateContentBlockAsync(CMSBlockAddEdit request, CancellationToken ct = default); + + Task UpdateContentBlockAsync(int contentBlockId, CMSBlockAddEdit request, CancellationToken ct = default); + + Task UpdateContentOnlyAsync(int contentBlockId, string content, DateTime? lastModifiedOn, CancellationToken ct = default); + + Task SoftDeleteAsync(int contentBlockId, CancellationToken ct = default); + + Task RestoreAsync(int contentBlockId, CancellationToken ct = default); + + Task PermanentlyDeleteAsync(int contentBlockId, CancellationToken ct = default); + + Task> GetHistoryAsync(int contentBlockId, CancellationToken ct = default); + + Task GetHistoryVersionAsync(int contentBlockId, int contentHistoryId, CancellationToken ct = default); + + Task> GetViperSectionPathsAsync(CancellationToken ct = default); + } + + /// + /// Management operations for CMS content blocks. Mirrors legacy ColdFusion + /// ContentBlocks.cfc semantics: content is stored raw and sanitized on render, + /// and ContentHistory receives the PREVIOUS content/author before each update + /// so history entries are the older versions, not copies of the current one. + /// + public class CmsContentBlockService : ICmsContentBlockService + { + private readonly VIPERContext _context; + private readonly IHtmlSanitizerService _sanitizer; + private readonly IUserHelper _userHelper; + + public CmsContentBlockService(VIPERContext context, IHtmlSanitizerService sanitizer, IUserHelper userHelper) + { + _context = context; + _sanitizer = sanitizer; + _userHelper = userHelper; + } + + public async Task> GetContentBlocksAsync(string status, string? system, + string? viperSectionPath, string? search, CancellationToken ct = default) + { + var query = _context.ContentBlocks + .AsNoTracking() + .AsSplitQuery() + .TagWith("CmsContentBlockService.GetContentBlocks") + .AsQueryable(); + + query = status.ToLowerInvariant() switch + { + "active" => query.Where(b => b.DeletedOn == null), + "deleted" => query.Where(b => b.DeletedOn != null), + _ => query + }; + + if (!string.IsNullOrEmpty(system)) + { + query = query.Where(b => b.System == system); + } + if (!string.IsNullOrEmpty(viperSectionPath)) + { + query = query.Where(b => b.ViperSectionPath == viperSectionPath); + } + if (!string.IsNullOrEmpty(search)) + { + query = query.Where(b => (b.Title != null && b.Title.Contains(search)) + || (b.FriendlyName != null && b.FriendlyName.Contains(search)) + || (b.Page != null && b.Page.Contains(search)) + || b.Content.Contains(search)); + } + + // List view: project without Content (it can be large) — the editor loads the full + // block. Two stages because GetFriendlyURL reads HttpContext and can't translate to SQL. + var blocks = await query + .OrderBy(b => b.Title) + .Select(b => new + { + b.ContentBlockId, + b.Title, + b.System, + b.Application, + b.Page, + b.ViperSectionPath, + b.BlockOrder, + b.FriendlyName, + b.AllowPublicAccess, + b.ModifiedOn, + b.ModifiedBy, + b.DeletedOn, + Permissions = b.ContentBlockToPermissions + .Select(p => p.Permission) + .OrderBy(p => p) + .ToList(), + Files = b.ContentBlockToFiles + .Where(f => f.File != null) + .Select(f => new + { + f.FileGuid, + f.File.FriendlyName, + f.File.AllowPublicAccess + }) + .OrderBy(f => f.FriendlyName) + .ToList() + }) + .ToListAsync(ct); + + return blocks.Select(b => new ContentBlockDto + { + ContentBlockId = b.ContentBlockId, + Title = b.Title, + System = b.System, + Application = b.Application, + Page = b.Page, + ViperSectionPath = b.ViperSectionPath, + BlockOrder = b.BlockOrder, + FriendlyName = b.FriendlyName, + AllowPublicAccess = b.AllowPublicAccess, + ModifiedOn = b.ModifiedOn, + ModifiedBy = b.ModifiedBy, + DeletedOn = b.DeletedOn, + Permissions = b.Permissions, + Files = b.Files.Select(f => new ContentBlockFileDto + { + FileGuid = f.FileGuid, + FriendlyName = f.FriendlyName, + Url = Data.CMS.GetFriendlyURL(f.FriendlyName, f.AllowPublicAccess) + }).ToList() + }).ToList(); + } + + public async Task GetContentBlockAsync(int contentBlockId, CancellationToken ct = default) + { + var block = await LoadBlockAsync(contentBlockId, tracking: false, ct); + if (block == null) + { + return null; + } + var dto = CmsContentBlockMapper.ToDto(block); + // Sanitize for the editor with the same policy used on render, so what the + // editor shows matches what viewers will see. + dto.Content = _sanitizer.Sanitize(dto.Content); + return dto; + } + + public async Task CreateContentBlockAsync(CMSBlockAddEdit request, CancellationToken ct = default) + { + await AssertFriendlyNameUniqueAsync(request.FriendlyName, null, ct); + var fileGuids = request.FileGuids.Distinct().ToList(); + await AssertFilesExistAsync(fileGuids, ct); + + var block = new ContentBlock(); + ApplyScalarFields(block, request); + block.ModifiedOn = DateTime.Now; + block.ModifiedBy = CurrentLoginId(); + + foreach (var permission in CleanList(request.Permissions)) + { + block.ContentBlockToPermissions.Add(new ContentBlockToPermission { Permission = permission }); + } + foreach (var fileGuid in fileGuids) + { + block.ContentBlockToFiles.Add(new ContentBlockToFile { FileGuid = fileGuid }); + } + + _context.ContentBlocks.Add(block); + await _context.SaveChangesAsync(ct); + + return (await GetContentBlockAsync(block.ContentBlockId, ct))!; + } + + public async Task UpdateContentBlockAsync(int contentBlockId, CMSBlockAddEdit request, CancellationToken ct = default) + { + var block = await LoadBlockAsync(contentBlockId, tracking: true, ct); + if (block == null) + { + return null; + } + + AssertNotStale(block, request.LastModifiedOn); + await AssertFriendlyNameUniqueAsync(request.FriendlyName, contentBlockId, ct); + var fileGuids = request.FileGuids.Distinct().ToList(); + await AssertFilesExistAsync(fileGuids, ct); + + SavePreviousVersionToHistory(block); + ApplyScalarFields(block, request); + block.ModifiedOn = DateTime.Now; + block.ModifiedBy = CurrentLoginId(); + + ApplyPermissionDeltas(block, CleanList(request.Permissions)); + ApplyFileDeltas(block, fileGuids); + + await _context.SaveChangesAsync(ct); + return await GetContentBlockAsync(contentBlockId, ct); + } + + public async Task UpdateContentOnlyAsync(int contentBlockId, string content, + DateTime? lastModifiedOn, CancellationToken ct = default) + { + var block = await LoadBlockAsync(contentBlockId, tracking: true, ct); + if (block == null) + { + return null; + } + + AssertNotStale(block, lastModifiedOn); + SavePreviousVersionToHistory(block); + block.Content = content; + block.ModifiedOn = DateTime.Now; + block.ModifiedBy = CurrentLoginId(); + + await _context.SaveChangesAsync(ct); + return await GetContentBlockAsync(contentBlockId, ct); + } + + public async Task SoftDeleteAsync(int contentBlockId, CancellationToken ct = default) + { + var block = await _context.ContentBlocks.FindAsync(new object[] { contentBlockId }, ct); + if (block == null) + { + return false; + } + block.DeletedOn = DateTime.Now; + block.ModifiedOn = DateTime.Now; + block.ModifiedBy = CurrentLoginId(); + await _context.SaveChangesAsync(ct); + return true; + } + + public async Task RestoreAsync(int contentBlockId, CancellationToken ct = default) + { + var block = await _context.ContentBlocks.FindAsync(new object[] { contentBlockId }, ct); + if (block == null) + { + return false; + } + block.DeletedOn = null; + block.ModifiedOn = DateTime.Now; + block.ModifiedBy = CurrentLoginId(); + await _context.SaveChangesAsync(ct); + return true; + } + + public async Task PermanentlyDeleteAsync(int contentBlockId, CancellationToken ct = default) + { + var block = await _context.ContentBlocks + .Include(b => b.ContentBlockToPermissions) + .Include(b => b.ContentBlockToFiles) + .Include(b => b.ContentHistories) + .AsSplitQuery() + .FirstOrDefaultAsync(b => b.ContentBlockId == contentBlockId, ct); + if (block == null) + { + return false; + } + + _context.RemoveRange(block.ContentHistories); + _context.RemoveRange(block.ContentBlockToFiles); + _context.RemoveRange(block.ContentBlockToPermissions); + _context.Remove(block); + await _context.SaveChangesAsync(ct); + return true; + } + + public async Task> GetHistoryAsync(int contentBlockId, CancellationToken ct = default) + { + return await _context.ContentHistories + .AsNoTracking() + .Where(h => h.ContentBlockId == contentBlockId) + .OrderByDescending(h => h.ModifiedOn) + .ThenByDescending(h => h.ContentHistoryId) + .Select(h => new ContentHistoryListItemDto + { + ContentHistoryId = h.ContentHistoryId, + ModifiedOn = h.ModifiedOn, + ModifiedBy = h.ModifiedBy + }) + .ToListAsync(ct); + } + + public async Task GetHistoryVersionAsync(int contentBlockId, int contentHistoryId, CancellationToken ct = default) + { + var history = await _context.ContentHistories + .AsNoTracking() + .FirstOrDefaultAsync(h => h.ContentBlockId == contentBlockId && h.ContentHistoryId == contentHistoryId, ct); + if (history == null) + { + return null; + } + return new ContentHistoryDto + { + ContentHistoryId = history.ContentHistoryId, + ModifiedOn = history.ModifiedOn, + ModifiedBy = history.ModifiedBy, + Content = _sanitizer.Sanitize(history.ContentBlockContent) + }; + } + + public async Task> GetViperSectionPathsAsync(CancellationToken ct = default) + { + return await _context.ContentBlocks + .AsNoTracking() + .Where(b => b.ViperSectionPath != null && b.ViperSectionPath != "") + .Select(b => b.ViperSectionPath!) + .Distinct() + .OrderBy(p => p) + .ToListAsync(ct); + } + + private async Task LoadBlockAsync(int contentBlockId, bool tracking, CancellationToken ct) + { + var query = _context.ContentBlocks + .Include(b => b.ContentBlockToPermissions) + .Include(b => b.ContentBlockToFiles) + .ThenInclude(f => f.File) + .AsSplitQuery(); + if (!tracking) + { + query = query.AsNoTracking(); + } + return await query.FirstOrDefaultAsync(b => b.ContentBlockId == contentBlockId, ct); + } + + private static void AssertNotStale(ContentBlock block, DateTime? lastModifiedOn) + { + if (lastModifiedOn == null) + { + throw new ArgumentException("LastModifiedOn is required so concurrent edits can be detected."); + } + // Compare to the second: serialized timestamps lose sub-second precision round-tripping + // through the client. + if (Math.Abs((block.ModifiedOn - lastModifiedOn.Value).TotalSeconds) >= 1) + { + throw new CmsConcurrencyException( + $"This content block was modified by {block.ModifiedBy} on {block.ModifiedOn:g}. Reload to get the latest version."); + } + } + + private async Task AssertFilesExistAsync(List fileGuids, CancellationToken ct) + { + if (fileGuids.Count == 0) + { + return; + } + int existing = await _context.Files.CountAsync(f => fileGuids.Contains(f.FileGuid), ct); + if (existing != fileGuids.Count) + { + throw new ArgumentException("One or more attached files do not exist."); + } + } + + private async Task AssertFriendlyNameUniqueAsync(string? friendlyName, int? exceptBlockId, CancellationToken ct) + { + if (string.IsNullOrEmpty(friendlyName)) + { + return; + } + bool taken = await _context.ContentBlocks + .AnyAsync(b => b.FriendlyName == friendlyName && (exceptBlockId == null || b.ContentBlockId != exceptBlockId), ct); + if (taken) + { + throw new ArgumentException("Friendly name must be unique"); + } + } + + private void SavePreviousVersionToHistory(ContentBlock block) + { + // Store the version being replaced, stamped with ITS author/time (legacy semantics). + _context.ContentHistories.Add(new ContentHistory + { + ContentBlockId = block.ContentBlockId, + ContentBlockContent = block.Content, + ModifiedOn = block.ModifiedOn, + ModifiedBy = block.ModifiedBy + }); + } + + private static void ApplyScalarFields(ContentBlock block, CMSBlockAddEdit request) + { + block.Title = request.Title; + block.Content = request.Content; + block.FriendlyName = request.FriendlyName; + block.System = request.System; + block.Application = request.Application; + block.Page = request.Page; + block.ViperSectionPath = request.ViperSectionPath; + block.AllowPublicAccess = request.AllowPublicAccess; + block.BlockOrder = request.BlockOrder; + } + + private void ApplyPermissionDeltas(ContentBlock block, List requested) + { + var existing = block.ContentBlockToPermissions.ToList(); + foreach (var cbp in existing.Where(cbp => !requested.Contains(cbp.Permission, StringComparer.OrdinalIgnoreCase))) + { + block.ContentBlockToPermissions.Remove(cbp); + _context.Remove(cbp); + } + var existingNames = existing.Select(p => p.Permission).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var permission in requested.Where(p => !existingNames.Contains(p))) + { + block.ContentBlockToPermissions.Add(new ContentBlockToPermission + { + ContentBlockId = block.ContentBlockId, + Permission = permission + }); + } + } + + private void ApplyFileDeltas(ContentBlock block, List requested) + { + var existing = block.ContentBlockToFiles.ToList(); + foreach (var cbf in existing.Where(cbf => !requested.Contains(cbf.FileGuid))) + { + block.ContentBlockToFiles.Remove(cbf); + _context.Remove(cbf); + } + var existingGuids = existing.Select(f => f.FileGuid).ToHashSet(); + foreach (var fileGuid in requested.Where(g => !existingGuids.Contains(g))) + { + block.ContentBlockToFiles.Add(new ContentBlockToFile + { + ContentBlockId = block.ContentBlockId, + FileGuid = fileGuid + }); + } + } + + private string CurrentLoginId() + { + return _userHelper.GetCurrentUser()?.LoginId ?? "unknown"; + } + + private static List CleanList(ICollection values) + { + return values + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/web/Areas/CMS/Services/CmsLeftNavService.cs b/web/Areas/CMS/Services/CmsLeftNavService.cs new file mode 100644 index 000000000..0a2c5aecb --- /dev/null +++ b/web/Areas/CMS/Services/CmsLeftNavService.cs @@ -0,0 +1,265 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Classes.SQLContext; +using Viper.Models.VIPER; + +namespace Viper.Areas.CMS.Services +{ + public interface ICmsLeftNavService + { + Task> GetMenusAsync(string? system, string? viperSectionPath, string? search, CancellationToken ct = default); + + Task GetMenuAsync(int leftNavMenuId, CancellationToken ct = default); + + Task CreateMenuAsync(LeftNavMenuAddEdit request, CancellationToken ct = default); + + Task UpdateMenuAsync(int leftNavMenuId, LeftNavMenuAddEdit request, CancellationToken ct = default); + + Task DeleteMenuAsync(int leftNavMenuId, CancellationToken ct = default); + + /// + /// Replace the menu's items with the supplied list: items with id 0 are added, + /// existing ids are updated, omitted ids are deleted, and DisplayOrder follows + /// the array order. Matches the legacy editor's batch save. + /// + Task SaveItemsAsync(int leftNavMenuId, List items, CancellationToken ct = default); + } + + /// + /// Management operations for CMS left navigation menus (legacy LeftNavs.cfc / + /// LeftNavAjax.cfc). Menu deletes cascade to items and item permissions; there is + /// no soft delete for menus, matching legacy. + /// + public class CmsLeftNavService : ICmsLeftNavService + { + private readonly VIPERContext _context; + private readonly IUserHelper _userHelper; + + public CmsLeftNavService(VIPERContext context, IUserHelper userHelper) + { + _context = context; + _userHelper = userHelper; + } + + public async Task> GetMenusAsync(string? system, string? viperSectionPath, string? search, CancellationToken ct = default) + { + var query = _context.LeftNavMenus + .AsNoTracking() + .Include(m => m.LeftNavItems) + .ThenInclude(i => i.LeftNavItemToPermissions) + .AsSplitQuery() + .TagWith("CmsLeftNavService.GetMenus") + .AsQueryable(); + + if (!string.IsNullOrEmpty(system)) + { + query = query.Where(m => m.System == system); + } + if (!string.IsNullOrEmpty(viperSectionPath)) + { + query = query.Where(m => m.ViperSectionPath == viperSectionPath); + } + if (!string.IsNullOrEmpty(search)) + { + query = query.Where(m => (m.MenuHeaderText != null && m.MenuHeaderText.Contains(search)) + || (m.FriendlyName != null && m.FriendlyName.Contains(search)) + || (m.Page != null && m.Page.Contains(search))); + } + + var menus = await query + .OrderBy(m => m.MenuHeaderText) + .ToListAsync(ct); + return menus.Select(ToDto).ToList(); + } + + public async Task GetMenuAsync(int leftNavMenuId, CancellationToken ct = default) + { + var menu = await LoadMenuAsync(leftNavMenuId, tracking: false, ct); + return menu == null ? null : ToDto(menu); + } + + public async Task CreateMenuAsync(LeftNavMenuAddEdit request, CancellationToken ct = default) + { + var menu = new LeftNavMenu(); + ApplyMenuFields(menu, request); + menu.ModifiedOn = DateTime.Now; + menu.ModifiedBy = CurrentLoginId(); + + _context.LeftNavMenus.Add(menu); + await _context.SaveChangesAsync(ct); + return ToDto(menu); + } + + public async Task UpdateMenuAsync(int leftNavMenuId, LeftNavMenuAddEdit request, CancellationToken ct = default) + { + var menu = await LoadMenuAsync(leftNavMenuId, tracking: true, ct); + if (menu == null) + { + return null; + } + + ApplyMenuFields(menu, request); + menu.ModifiedOn = DateTime.Now; + menu.ModifiedBy = CurrentLoginId(); + + await _context.SaveChangesAsync(ct); + return ToDto(menu); + } + + public async Task DeleteMenuAsync(int leftNavMenuId, CancellationToken ct = default) + { + var menu = await LoadMenuAsync(leftNavMenuId, tracking: true, ct); + if (menu == null) + { + return false; + } + + foreach (var item in menu.LeftNavItems) + { + _context.RemoveRange(item.LeftNavItemToPermissions); + } + _context.RemoveRange(menu.LeftNavItems); + _context.Remove(menu); + await _context.SaveChangesAsync(ct); + return true; + } + + public async Task SaveItemsAsync(int leftNavMenuId, List items, CancellationToken ct = default) + { + var menu = await LoadMenuAsync(leftNavMenuId, tracking: true, ct); + if (menu == null) + { + return null; + } + + var requestedIds = items.Where(i => i.LeftNavItemId > 0).Select(i => i.LeftNavItemId).ToHashSet(); + + // Delete items not present in the request. + foreach (var item in menu.LeftNavItems.Where(i => !requestedIds.Contains(i.LeftNavItemId)).ToList()) + { + _context.RemoveRange(item.LeftNavItemToPermissions); + _context.Remove(item); + menu.LeftNavItems.Remove(item); + } + + var existingById = menu.LeftNavItems.ToDictionary(i => i.LeftNavItemId); + int order = 1; + foreach (var requested in items) + { + if (requested.LeftNavItemId > 0 && existingById.TryGetValue(requested.LeftNavItemId, out var existing)) + { + existing.MenuItemText = requested.MenuItemText; + existing.IsHeader = requested.IsHeader; + existing.Url = requested.IsHeader ? null : requested.Url; + existing.DisplayOrder = order; + ApplyItemPermissionDeltas(existing, CleanList(requested.Permissions)); + } + else + { + var newItem = new LeftNavItem + { + LeftNavMenuId = leftNavMenuId, + MenuItemText = requested.MenuItemText, + IsHeader = requested.IsHeader, + Url = requested.IsHeader ? null : requested.Url, + DisplayOrder = order + }; + foreach (var permission in CleanList(requested.Permissions)) + { + newItem.LeftNavItemToPermissions.Add(new LeftNavItemToPermission { Permission = permission }); + } + menu.LeftNavItems.Add(newItem); + } + order++; + } + + menu.ModifiedOn = DateTime.Now; + menu.ModifiedBy = CurrentLoginId(); + + await _context.SaveChangesAsync(ct); + return await GetMenuAsync(leftNavMenuId, ct); + } + + private async Task LoadMenuAsync(int leftNavMenuId, bool tracking, CancellationToken ct) + { + var query = _context.LeftNavMenus + .Include(m => m.LeftNavItems) + .ThenInclude(i => i.LeftNavItemToPermissions) + .AsSplitQuery(); + if (!tracking) + { + query = query.AsNoTracking(); + } + return await query.FirstOrDefaultAsync(m => m.LeftNavMenuId == leftNavMenuId, ct); + } + + private void ApplyItemPermissionDeltas(LeftNavItem item, List requested) + { + var existing = item.LeftNavItemToPermissions.ToList(); + foreach (var p in existing.Where(p => !requested.Contains(p.Permission, StringComparer.OrdinalIgnoreCase))) + { + item.LeftNavItemToPermissions.Remove(p); + _context.Remove(p); + } + var existingNames = existing.Select(p => p.Permission).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var permission in requested.Where(p => !existingNames.Contains(p))) + { + item.LeftNavItemToPermissions.Add(new LeftNavItemToPermission + { + LeftNavItemId = item.LeftNavItemId, + Permission = permission + }); + } + } + + private static void ApplyMenuFields(LeftNavMenu menu, LeftNavMenuAddEdit request) + { + menu.MenuHeaderText = request.MenuHeaderText; + menu.System = request.System; + menu.ViperSectionPath = request.ViperSectionPath; + menu.Page = request.Page; + menu.FriendlyName = request.FriendlyName; + } + + private static LeftNavMenuDto ToDto(LeftNavMenu menu) + { + return new LeftNavMenuDto + { + LeftNavMenuId = menu.LeftNavMenuId, + MenuHeaderText = menu.MenuHeaderText, + System = menu.System, + ViperSectionPath = menu.ViperSectionPath, + Page = menu.Page, + FriendlyName = menu.FriendlyName, + ModifiedOn = menu.ModifiedOn, + ModifiedBy = menu.ModifiedBy, + Items = menu.LeftNavItems + .OrderBy(i => i.DisplayOrder) + .Select(i => new LeftNavItemDto + { + LeftNavItemId = i.LeftNavItemId, + MenuItemText = i.MenuItemText, + IsHeader = i.IsHeader, + Url = i.Url, + DisplayOrder = i.DisplayOrder, + Permissions = i.LeftNavItemToPermissions.Select(p => p.Permission).OrderBy(p => p).ToList() + }) + .ToList() + }; + } + + private string CurrentLoginId() + { + return _userHelper.GetCurrentUser()?.LoginId ?? "unknown"; + } + + private static List CleanList(List values) + { + return values + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/web/Areas/CMS/Validation/SafeUrlAttribute.cs b/web/Areas/CMS/Validation/SafeUrlAttribute.cs index 8b7f893d7..7055a784e 100644 --- a/web/Areas/CMS/Validation/SafeUrlAttribute.cs +++ b/web/Areas/CMS/Validation/SafeUrlAttribute.cs @@ -7,6 +7,12 @@ public sealed class SafeUrlAttribute : ValidationAttribute { private static readonly string[] AllowedSchemes = { "http", "https", "mailto", "tel" }; + /// + /// Also accept relative URLs (e.g. "/CMS/files", "~/page", "page.cfm?x=1"). + /// Anything a browser could parse as a scheme is still restricted to the allowlist. + /// + public bool AllowRelative { get; set; } + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) { if (value is not string url || string.IsNullOrEmpty(url)) @@ -14,16 +20,27 @@ public sealed class SafeUrlAttribute : ValidationAttribute return ValidationResult.Success; } - if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri)) + url = url.Trim(); + + // Browsers strip tabs/newlines when parsing hrefs, so "java\tscript:" still runs + if (url.Any(char.IsControl)) { - return new ValidationResult("URL must be a full address starting with http, https, mailto, or tel."); + return new ValidationResult("URL contains invalid characters."); } - if (!AllowedSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase)) + // A colon in the first path segment is what browsers parse as a scheme; + // checking it directly avoids Uri's platform-dependent handling of + // rooted paths like "/CMS/files" (parsed as file:// on Unix) + if (!url.Split('/', '?', '#')[0].Contains(':')) { - return new ValidationResult("URL protocol must be http, https, mailto, or tel."); + return AllowRelative + ? ValidationResult.Success + : new ValidationResult("URL must be a full address starting with http, https, mailto, or tel."); } - return ValidationResult.Success; + return Uri.TryCreate(url, UriKind.Absolute, out var uri) + && AllowedSchemes.Contains(uri.Scheme, StringComparer.OrdinalIgnoreCase) + ? ValidationResult.Success + : new ValidationResult("URL protocol must be http, https, mailto, or tel."); } }