From 8c91ae3bc74d5c5e3a0946a3ed6d4f646da5f3c7 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 11 Jun 2026 00:29:07 -0700 Subject: [PATCH 01/40] VPR-59 feat(cms): add file management API - New /api/cms/files endpoints: paged list with filters, get, folders, multipart upload, edit with permission/person deltas, encrypt toggle, file replacement, soft delete/restore, admin permanent delete, and a queryable fileAudit log matching legacy audit actions - CmsFileCrypto implements ColdFusion-compatible AES-128-ECB with UU-encoded per-file keys so both systems can read each other's encrypted files during coexistence; CMS.cs decrypt now delegates to it - CmsFileStorageService validates folders against the storage root (containment + top-level whitelist), uploads via temp-then-move, and reproduces legacy _0.._999 collision renaming - Encrypt/decrypt-in-place writes are atomic (temp file + move) to avoid corrupting files on interrupted writes --- test/CMS/CmsFileCryptoTests.cs | 110 ++++ test/CMS/CmsFileServiceTests.cs | 399 +++++++++++++++ test/CMS/CmsFileStorageServiceTests.cs | 290 +++++++++++ web/Areas/CMS/Constants/CmsFileTypes.cs | 56 ++ web/Areas/CMS/Constants/CmsPermissions.cs | 15 + .../CMS/Controllers/CMSFilesController.cs | 204 ++++++++ web/Areas/CMS/Data/CMS.cs | 102 +--- web/Areas/CMS/Models/CmsFileMapper.cs | 39 ++ web/Areas/CMS/Models/DTOs/CmsFileDto.cs | 27 + web/Areas/CMS/Models/DTOs/CmsFileRequests.cs | 50 ++ web/Areas/CMS/Services/CmsFileAuditService.cs | 131 +++++ web/Areas/CMS/Services/CmsFileCrypto.cs | 121 +++++ .../CMS/Services/CmsFileEncryptionService.cs | 99 ++++ web/Areas/CMS/Services/CmsFileService.cs | 478 ++++++++++++++++++ .../CMS/Services/CmsFileStorageService.cs | 204 ++++++++ 15 files changed, 2229 insertions(+), 96 deletions(-) create mode 100644 test/CMS/CmsFileCryptoTests.cs create mode 100644 test/CMS/CmsFileServiceTests.cs create mode 100644 test/CMS/CmsFileStorageServiceTests.cs create mode 100644 web/Areas/CMS/Constants/CmsFileTypes.cs create mode 100644 web/Areas/CMS/Constants/CmsPermissions.cs create mode 100644 web/Areas/CMS/Controllers/CMSFilesController.cs create mode 100644 web/Areas/CMS/Models/CmsFileMapper.cs create mode 100644 web/Areas/CMS/Models/DTOs/CmsFileDto.cs create mode 100644 web/Areas/CMS/Models/DTOs/CmsFileRequests.cs create mode 100644 web/Areas/CMS/Services/CmsFileAuditService.cs create mode 100644 web/Areas/CMS/Services/CmsFileCrypto.cs create mode 100644 web/Areas/CMS/Services/CmsFileEncryptionService.cs create mode 100644 web/Areas/CMS/Services/CmsFileService.cs create mode 100644 web/Areas/CMS/Services/CmsFileStorageService.cs diff --git a/test/CMS/CmsFileCryptoTests.cs b/test/CMS/CmsFileCryptoTests.cs new file mode 100644 index 000000000..f0ec27114 --- /dev/null +++ b/test/CMS/CmsFileCryptoTests.cs @@ -0,0 +1,110 @@ +using System.Security.Cryptography; +using System.Text; +using Viper.Areas.CMS.Services; + +namespace Viper.test.CMS; + +/// +/// Tests for the CF-compatible CMS file crypto primitives. The DB key format +/// (UUEncode over AES-ECB) and content encryption must round-trip so files written +/// by the new system stay readable by the legacy ColdFusion CMS and vice versa. +/// +public sealed class CmsFileCryptoTests +{ + private static string NewMasterKey() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + + [Fact] + public void GenerateFileKey_IsBase64Of128BitKey() + { + var key = CmsFileCrypto.GenerateFileKey(); + + var bytes = Convert.FromBase64String(key); + Assert.Equal(16, bytes.Length); + } + + [Fact] + public void DbKey_RoundTrips_ThroughEncryptAndDecrypt() + { + var masterKey = NewMasterKey(); + var fileKey = CmsFileCrypto.GenerateFileKey(); + + var dbKey = CmsFileCrypto.EncryptDbKey(fileKey, masterKey); + var decrypted = CmsFileCrypto.DecryptDbKey(dbKey, masterKey); + + Assert.Equal(fileKey, decrypted); + // Stored keys must be printable (UU-encoded) for the varchar column. + Assert.All(dbKey, c => Assert.True(c < 127)); + } + + [Fact] + public void DbKey_DiffersPerCall_EvenForSameFileKey() + { + // Same plaintext under different master keys must never produce the same stored key. + var fileKey = CmsFileCrypto.GenerateFileKey(); + + var dbKey1 = CmsFileCrypto.EncryptDbKey(fileKey, NewMasterKey()); + var dbKey2 = CmsFileCrypto.EncryptDbKey(fileKey, NewMasterKey()); + + Assert.NotEqual(dbKey1, dbKey2); + } + + [Fact] + public void FileContents_RoundTrip_ThroughEncryptAndDecrypt() + { + var fileKey = CmsFileCrypto.GenerateFileKey(); + var contents = Encoding.UTF8.GetBytes("PDF-1.7 pretend file contents éü with some length to cross a block boundary."); + + var encrypted = CmsFileCrypto.EncryptBytes(contents, fileKey); + var decrypted = CmsFileCrypto.DecryptBytes(encrypted, fileKey); + + Assert.NotEqual(contents, encrypted); + Assert.Equal(contents, decrypted); + } + + [Fact] + public void FullFlow_DbKeyPlusContents_MatchesLegacyDecryptPath() + { + // Mirrors CMS.DecryptFile: db key -> per-file key -> ECB decrypt of contents. + var masterKey = NewMasterKey(); + var fileKey = CmsFileCrypto.GenerateFileKey(); + var dbKey = CmsFileCrypto.EncryptDbKey(fileKey, masterKey); + var contents = RandomNumberGenerator.GetBytes(1024); + + var encrypted = CmsFileCrypto.EncryptBytes(contents, fileKey); + var decrypted = CmsFileCrypto.DecryptBytes(encrypted, CmsFileCrypto.DecryptDbKey(dbKey, masterKey)); + + Assert.Equal(contents, decrypted); + } + + [Fact] + public void ReadMasterKey_ReadsSecondLine() + { + var path = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); + try + { + File.WriteAllLines(path, new[] { "first line is ignored", " the-master-key ", "third" }); + + Assert.Equal("the-master-key", CmsFileCrypto.ReadMasterKey(path)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ReadMasterKey_MissingKeyLine_Throws() + { + var path = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".txt"); + try + { + File.WriteAllLines(path, new[] { "only one line" }); + + Assert.Throws(() => CmsFileCrypto.ReadMasterKey(path)); + } + finally + { + File.Delete(path); + } + } +} diff --git a/test/CMS/CmsFileServiceTests.cs b/test/CMS/CmsFileServiceTests.cs new file mode 100644 index 000000000..c0793605c --- /dev/null +++ b/test/CMS/CmsFileServiceTests.cs @@ -0,0 +1,399 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; +using Viper.Classes.SQLContext; +using File = Viper.Models.VIPER.File; + +namespace Viper.test.CMS; + +/// +/// Tests for CmsFileService CRUD orchestration: friendly-name generation, permission and +/// person deltas, soft delete/restore, and audit calls. Storage and encryption are mocked; +/// the database is EF in-memory. +/// +public sealed class CmsFileServiceTests : IDisposable +{ + private readonly VIPERContext _context; + private readonly AAUDContext _aaudContext; + private readonly ICmsFileStorageService _storage; + private readonly ICmsFileEncryptionService _encryption; + private readonly ICmsFileAuditService _audit; + private readonly IUserHelper _userHelper; + private readonly CmsFileService _service; + + public CmsFileServiceTests() + { + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + _aaudContext = new AAUDContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("AAUD_" + Guid.NewGuid()).Options); + + _storage = Substitute.For(); + _storage.RootFolder.Returns(@"C:\FakeRoot"); + _storage.IsValidFolder(Arg.Any()).Returns(true); + _storage.SaveToTempAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => Task.FromResult(@"C:\FakeTemp\" + Guid.NewGuid().ToString("N"))); + _storage.MoveIntoPlace(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => Path.Join(@"C:\FakeRoot", callInfo.ArgAt(1), Path.GetFileName(callInfo.ArgAt(2)))); + + _encryption = Substitute.For(); + _encryption.GenerateKeyForDb().Returns("encrypted-db-key"); + + _audit = Substitute.For(); + _userHelper = Substitute.For(); + + _service = new CmsFileService(_context, _aaudContext, _storage, _encryption, _audit, _userHelper); + } + + public void Dispose() + { + _context.Dispose(); + _aaudContext.Dispose(); + } + + private static IFormFile MakeFormFile(string fileName = "report.pdf") + { + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + return new FormFile(stream, 0, stream.Length, "file", fileName); + } + + private async Task SeedFileAsync(Action? customize = null) + { + var file = new File + { + FileGuid = Guid.NewGuid(), + FilePath = @"C:\FakeRoot\cats\seeded.pdf", + Folder = "cats", + FriendlyName = "cats-seeded.pdf", + Description = "seeded", + ModifiedBy = "test", + ModifiedOn = DateTime.Now.AddDays(-1) + }; + customize?.Invoke(file); + _context.Files.Add(file); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return file; + } + + #region Create + + [Fact] + public async Task CreateFile_BuildsFriendlyNameFromFolderAndFileName() + { + var request = new CmsFileCreateRequest { Folder = @"cats\photos" }; + + var dto = await _service.CreateFileAsync(request, MakeFormFile("pic.jpg"), TestContext.Current.CancellationToken); + + Assert.Equal("cats-photos-pic.jpg", dto.FriendlyName); + Assert.Equal("pic.jpg", dto.FileName); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal(@"cats\photos", saved.Folder); + Assert.False(saved.Encrypted); + Assert.Null(saved.Key); + } + + [Fact] + public async Task CreateFile_SavesPermissionsAndPeople_Deduplicated() + { + var request = new CmsFileCreateRequest + { + Folder = "cats", + Permissions = new List { "SVMSecure.CATS", "svmsecure.cats", " SVMSecure.Admin " }, + IamIds = new List { "1000123", "1000123", "1000456" } + }; + + var dto = await _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken); + + Assert.Equal(2, dto.Permissions.Count); + Assert.Contains("SVMSecure.Admin", dto.Permissions); + Assert.Equal(2, dto.People.Count); + } + + [Fact] + public async Task CreateFile_WithEncrypt_GeneratesKeyAndEncryptsTemp() + { + var request = new CmsFileCreateRequest { Folder = "cats", Encrypt = true }; + + var dto = await _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken); + + Assert.True(dto.Encrypted); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal("encrypted-db-key", saved.Key); + _encryption.Received(1).EncryptFileInPlace(Arg.Any(), "encrypted-db-key"); + } + + [Fact] + public async Task CreateFile_AuditsAddAndUpload() + { + var request = new CmsFileCreateRequest { Folder = "cats" }; + + await _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken); + + _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.AddFile, Arg.Any()); + _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.UploadFile, "NewFile"); + } + + [Fact] + public async Task CreateFile_DuplicateFriendlyName_DeletesMovedFileAndThrows() + { + await SeedFileAsync(f => f.FriendlyName = "cats-report.pdf"); + var request = new CmsFileCreateRequest { Folder = "cats" }; + + await Assert.ThrowsAsync( + () => _service.CreateFileAsync(request, MakeFormFile("report.pdf"), TestContext.Current.CancellationToken)); + + _storage.Received(1).DeleteManagedFile(@"C:\FakeRoot\cats\report.pdf"); + } + + [Fact] + public async Task CreateFile_InvalidFolder_Throws() + { + _storage.IsValidFolder("badfolder").Returns(false); + var request = new CmsFileCreateRequest { Folder = "badfolder" }; + + await Assert.ThrowsAsync( + () => _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CreateFile_DisallowedExtension_Throws() + { + var request = new CmsFileCreateRequest { Folder = "cats" }; + + await Assert.ThrowsAsync( + () => _service.CreateFileAsync(request, MakeFormFile("evil.bat"), TestContext.Current.CancellationToken)); + } + + #endregion + + #region Update + + [Fact] + public async Task UpdateFile_AppliesPermissionDeltas() + { + var file = await SeedFileAsync(f => + { + f.FileToPermissions.Add(new Viper.Models.VIPER.FileToPermission { FileGuid = f.FileGuid, Permission = "SVMSecure.Old" }); + f.FileToPermissions.Add(new Viper.Models.VIPER.FileToPermission { FileGuid = f.FileGuid, Permission = "SVMSecure.Keep" }); + }); + var request = new CmsFileUpdateRequest + { + Description = "seeded", + Permissions = new List { "SVMSecure.Keep", "SVMSecure.New" } + }; + + var dto = await _service.UpdateFileAsync(file.FileGuid, request, null, TestContext.Current.CancellationToken); + + Assert.NotNull(dto); + Assert.Equal(new List { "SVMSecure.Keep", "SVMSecure.New" }, dto!.Permissions); + _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.EditFile, + Arg.Is(d => d.Contains("Permission removed: SVMSecure.Old") && d.Contains("Permission added: SVMSecure.New"))); + } + + [Fact] + public async Task UpdateFile_AppliesPersonDeltas() + { + var file = await SeedFileAsync(f => + f.FileToPeople.Add(new Viper.Models.VIPER.FileToPerson { FileGuid = f.FileGuid, IamId = "100OLD" })); + var request = new CmsFileUpdateRequest + { + Description = "seeded", + IamIds = new List { "100NEW" } + }; + + var dto = await _service.UpdateFileAsync(file.FileGuid, request, null, TestContext.Current.CancellationToken); + + Assert.NotNull(dto); + Assert.Single(dto!.People); + Assert.Equal("100NEW", dto.People[0].IamId); + } + + [Fact] + public async Task UpdateFile_EncryptToggleOn_EncryptsExistingFile() + { + var file = await SeedFileAsync(); + _storage.ManagedFileExists(file.FilePath).Returns(true); + var request = new CmsFileUpdateRequest { Description = "seeded", Encrypt = true }; + + var dto = await _service.UpdateFileAsync(file.FileGuid, request, null, TestContext.Current.CancellationToken); + + Assert.True(dto!.Encrypted); + _encryption.Received(1).EncryptFileInPlace(file.FilePath, "encrypted-db-key"); + } + + [Fact] + public async Task UpdateFile_EncryptToggleOff_DecryptsExistingFile() + { + var file = await SeedFileAsync(f => + { + f.Encrypted = true; + f.Key = "old-key"; + }); + _storage.ManagedFileExists(file.FilePath).Returns(true); + var request = new CmsFileUpdateRequest { Description = "seeded", Encrypt = false }; + + var dto = await _service.UpdateFileAsync(file.FileGuid, request, null, TestContext.Current.CancellationToken); + + Assert.False(dto!.Encrypted); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.Null(saved.Key); + _encryption.Received(1).DecryptFileInPlace(file.FilePath, "old-key"); + } + + [Fact] + public async Task UpdateFile_WithReplacementUpload_ReplacesInPlaceAndAudits() + { + var file = await SeedFileAsync(); + var request = new CmsFileUpdateRequest { Description = "seeded" }; + + await _service.UpdateFileAsync(file.FileGuid, request, MakeFormFile("newversion.pdf"), TestContext.Current.CancellationToken); + + _storage.Received(1).ReplaceInPlace(Arg.Any(), file.FilePath); + _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.UploadFile, "ReplacingFile"); + } + + [Fact] + public async Task UpdateFile_UnknownGuid_ReturnsNull() + { + var dto = await _service.UpdateFileAsync(Guid.NewGuid(), new CmsFileUpdateRequest(), null, TestContext.Current.CancellationToken); + + Assert.Null(dto); + } + + #endregion + + #region Delete / Restore + + [Fact] + public async Task SoftDelete_SetsDeletedOn_AndAudits() + { + var file = await SeedFileAsync(); + + var result = await _service.SoftDeleteFileAsync(file.FileGuid, TestContext.Current.CancellationToken); + + Assert.True(result); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.NotNull(saved.DeletedOn); + _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.DeleteFile, "File Marked for Deletion"); + } + + [Fact] + public async Task Restore_ClearsDeletedOn_AndAudits() + { + var file = await SeedFileAsync(f => f.DeletedOn = DateTime.Now); + + var result = await _service.RestoreFileAsync(file.FileGuid, TestContext.Current.CancellationToken); + + Assert.True(result); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.Null(saved.DeletedOn); + _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.CancelDelete, Arg.Any()); + } + + [Fact] + public async Task PermanentDelete_RemovesRowAndDiskFile() + { + var file = await SeedFileAsync(f => + f.FileToPermissions.Add(new Viper.Models.VIPER.FileToPermission { FileGuid = f.FileGuid, Permission = "SVMSecure" })); + _storage.ManagedFileExists(file.FilePath).Returns(true); + + var result = await _service.PermanentlyDeleteFileAsync(file.FileGuid, TestContext.Current.CancellationToken); + + Assert.True(result); + Assert.Empty(await _context.Files.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.FileToPermissions.ToListAsync(TestContext.Current.CancellationToken)); + _storage.Received(1).DeleteManagedFile(file.FilePath); + _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.DeleteFile, "File Deleted"); + } + + #endregion + + #region List + + [Fact] + public async Task GetFiles_FiltersByStatus() + { + await SeedFileAsync(); + await SeedFileAsync(f => + { + f.FriendlyName = "cats-deleted.pdf"; + f.FilePath = @"C:\FakeRoot\cats\deleted.pdf"; + f.DeletedOn = DateTime.Now; + }); + + var (active, activeTotal) = await _service.GetFilesAsync(null, "active", null, null, null, 1, 50, null, false, TestContext.Current.CancellationToken); + var (deleted, deletedTotal) = await _service.GetFilesAsync(null, "deleted", null, null, null, 1, 50, null, false, TestContext.Current.CancellationToken); + var (all, allTotal) = await _service.GetFilesAsync(null, "all", null, null, null, 1, 50, null, false, TestContext.Current.CancellationToken); + + Assert.Equal(1, activeTotal); + Assert.Single(active); + Assert.Equal(1, deletedTotal); + Assert.Single(deleted); + Assert.Equal(2, allTotal); + Assert.Equal(2, all.Count); + } + + [Fact] + public async Task GetFiles_FiltersByFolderIncludingSubfolders() + { + await SeedFileAsync(); + await SeedFileAsync(f => + { + f.Folder = @"cats\photos"; + f.FriendlyName = "cats-photos-pic.jpg"; + f.FilePath = @"C:\FakeRoot\cats\photos\pic.jpg"; + }); + await SeedFileAsync(f => + { + f.Folder = "students"; + f.FriendlyName = "students-doc.pdf"; + f.FilePath = @"C:\FakeRoot\students\doc.pdf"; + }); + + var (files, total) = await _service.GetFilesAsync("cats", "active", null, null, null, 1, 50, null, false, TestContext.Current.CancellationToken); + + Assert.Equal(2, total); + Assert.All(files, f => Assert.StartsWith("cats", f.Folder)); + } + + [Fact] + public async Task GetFiles_SearchMatchesFriendlyNameAndDescription() + { + await SeedFileAsync(f => f.Description = "the yearly budget"); + await SeedFileAsync(f => + { + f.FriendlyName = "cats-other.pdf"; + f.FilePath = @"C:\FakeRoot\cats\other.pdf"; + f.Description = "unrelated"; + }); + + var (files, total) = await _service.GetFilesAsync(null, "active", "budget", null, null, 1, 50, null, false, TestContext.Current.CancellationToken); + + Assert.Equal(1, total); + Assert.Equal("cats-seeded.pdf", files[0].FriendlyName); + } + + [Fact] + public async Task GetFiles_PaginationAppliesSkipTake() + { + for (int i = 0; i < 5; i++) + { + var index = i; + await SeedFileAsync(f => + { + f.FriendlyName = $"cats-file{index}.pdf"; + f.FilePath = $@"C:\FakeRoot\cats\file{index}.pdf"; + }); + } + + var (page2, total) = await _service.GetFilesAsync(null, "active", null, null, null, 2, 2, "friendlyName", false, TestContext.Current.CancellationToken); + + Assert.Equal(5, total); + Assert.Equal(2, page2.Count); + Assert.Equal("cats-file2.pdf", page2[0].FriendlyName); + } + + #endregion +} diff --git a/test/CMS/CmsFileStorageServiceTests.cs b/test/CMS/CmsFileStorageServiceTests.cs new file mode 100644 index 000000000..dfd6da80f --- /dev/null +++ b/test/CMS/CmsFileStorageServiceTests.cs @@ -0,0 +1,290 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Viper.Areas.CMS.Services; +using Viper.Classes.SQLContext; + +namespace Viper.test.CMS; + +/// +/// Tests for CmsFileStorageService: folder validation (user input must never escape the +/// storage root), name-collision handling, and managed-file containment checks. +/// +public sealed class CmsFileStorageServiceTests : IDisposable +{ + private readonly string _root; + private readonly VIPERContext _context; + private readonly CmsFileStorageService _service; + + public CmsFileStorageServiceTests() + { + _root = Path.Join(Path.GetTempPath(), "ViperCmsStorageTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Join(_root, "cats")); + Directory.CreateDirectory(Path.Join(_root, "students")); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new VIPERContext(options); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["CMS:FileStorageRoot"] = _root }) + .Build(); + _service = new CmsFileStorageService(_context, configuration); + } + + public void Dispose() + { + _context.Dispose(); + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + + private static string CreateTempFile(string contents = "test contents") + { + var path = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + File.WriteAllText(path, contents); + return path; + } + + #region GetTopLevelFolders / IsValidFolder + + [Fact] + public void GetTopLevelFolders_ReturnsDirectoriesUnderRoot() + { + var folders = _service.GetTopLevelFolders(); + + Assert.Equal(new[] { "cats", "students" }, folders); + } + + [Theory] + [InlineData("cats")] + [InlineData("students")] + [InlineData(@"cats\photos")] + [InlineData("cats/photos/2026")] + public void IsValidFolder_AcceptsExistingTopLevelAndSubfolders(string folder) + { + Assert.True(_service.IsValidFolder(folder)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("doesnotexist")] + [InlineData(@"..\cats")] + [InlineData(@"cats\..\..\evil")] + [InlineData(@"cats\.")] + [InlineData(@"C:\Windows")] + [InlineData(@"\\server\share")] + [InlineData("cats\\sub")] + public void IsValidFolder_RejectsUnknownTraversalAndInvalid(string folder) + { + Assert.False(_service.IsValidFolder(folder)); + } + + #endregion + + #region MoveIntoPlace + + [Fact] + public void MoveIntoPlace_MovesTempFileIntoFolder() + { + var temp = CreateTempFile(); + + var finalPath = _service.MoveIntoPlace(temp, "cats", "report.pdf", makeUnique: false); + + Assert.Equal(Path.Join(_root, "cats", "report.pdf"), finalPath); + Assert.True(File.Exists(finalPath)); + Assert.False(File.Exists(temp)); + } + + [Fact] + public void MoveIntoPlace_CreatesSubfolders() + { + var temp = CreateTempFile(); + + var finalPath = _service.MoveIntoPlace(temp, @"cats\photos", "pic.jpg", makeUnique: false); + + Assert.Equal(Path.Join(_root, "cats", "photos", "pic.jpg"), finalPath); + Assert.True(File.Exists(finalPath)); + } + + [Fact] + public void MoveIntoPlace_Conflict_WithoutMakeUnique_Throws() + { + File.WriteAllText(Path.Join(_root, "cats", "report.pdf"), "existing"); + var temp = CreateTempFile(); + + Assert.Throws(() => _service.MoveIntoPlace(temp, "cats", "report.pdf", makeUnique: false)); + } + + [Fact] + public void MoveIntoPlace_Conflict_WithMakeUnique_AppendsCounter() + { + File.WriteAllText(Path.Join(_root, "cats", "report.pdf"), "existing"); + File.WriteAllText(Path.Join(_root, "cats", "report_0.pdf"), "existing"); + var temp = CreateTempFile(); + + var finalPath = _service.MoveIntoPlace(temp, "cats", "report.pdf", makeUnique: true); + + Assert.Equal(Path.Join(_root, "cats", "report_1.pdf"), finalPath); + } + + [Fact] + public void MoveIntoPlace_StripsPathComponentsFromFileName() + { + var temp = CreateTempFile(); + + var finalPath = _service.MoveIntoPlace(temp, "cats", @"..\..\evil.pdf", makeUnique: false); + + Assert.Equal(Path.Join(_root, "cats", "evil.pdf"), finalPath); + } + + [Theory] + [InlineData("malware.bat")] + [InlineData("script.ps1")] + [InlineData("noextension")] + public void MoveIntoPlace_DisallowedExtension_Throws(string fileName) + { + var temp = CreateTempFile(); + + try + { + Assert.Throws(() => _service.MoveIntoPlace(temp, "cats", fileName, makeUnique: false)); + } + finally + { + File.Delete(temp); + } + } + + [Fact] + public void MoveIntoPlace_InvalidFolder_Throws() + { + var temp = CreateTempFile(); + + try + { + Assert.Throws(() => _service.MoveIntoPlace(temp, @"..\evil", "report.pdf", makeUnique: false)); + } + finally + { + File.Delete(temp); + } + } + + #endregion + + #region FileNameInUse + + [Fact] + public void FileNameInUse_DetectsFileOnDisk() + { + File.WriteAllText(Path.Join(_root, "cats", "ondisk.pdf"), "x"); + + Assert.True(_service.FileNameInUse("cats", "ondisk.pdf")); + Assert.False(_service.FileNameInUse("cats", "notthere.pdf")); + } + + [Fact] + public void FileNameInUse_DetectsDatabaseRecordWithoutDiskFile() + { + _context.Files.Add(new Viper.Models.VIPER.File + { + FileGuid = Guid.NewGuid(), + FilePath = Path.Join(_root, "cats", "dbonly.pdf"), + Folder = "cats", + FriendlyName = "cats-dbonly.pdf", + Description = "", + ModifiedBy = "test", + ModifiedOn = DateTime.Now + }); + _context.SaveChanges(); + + Assert.True(_service.FileNameInUse("cats", "dbonly.pdf")); + } + + #endregion + + #region SaveToTempAsync / ReplaceInPlace / DeleteManagedFile + + [Fact] + public async Task SaveToTempAsync_WritesUploadOutsideStorageRoot() + { + using var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + var formFile = new FormFile(stream, 0, stream.Length, "file", "upload.pdf"); + + var tempPath = await _service.SaveToTempAsync(formFile, TestContext.Current.CancellationToken); + + try + { + Assert.True(File.Exists(tempPath)); + Assert.False(tempPath.StartsWith(_root, StringComparison.OrdinalIgnoreCase)); + } + finally + { + File.Delete(tempPath); + } + } + + [Fact] + public void ReplaceInPlace_OverwritesManagedFile() + { + var managed = Path.Join(_root, "cats", "replace.pdf"); + File.WriteAllText(managed, "old"); + var temp = CreateTempFile("new"); + + _service.ReplaceInPlace(temp, managed); + + Assert.Equal("new", File.ReadAllText(managed)); + Assert.False(File.Exists(temp)); + } + + [Fact] + public void ReplaceInPlace_TargetOutsideRoot_Throws() + { + var outside = CreateTempFile("victim"); + var temp = CreateTempFile("new"); + + try + { + Assert.Throws(() => _service.ReplaceInPlace(temp, outside)); + } + finally + { + File.Delete(outside); + File.Delete(temp); + } + } + + [Fact] + public void DeleteManagedFile_DeletesUnderRoot() + { + var managed = Path.Join(_root, "cats", "todelete.pdf"); + File.WriteAllText(managed, "x"); + + _service.DeleteManagedFile(managed); + + Assert.False(File.Exists(managed)); + } + + [Fact] + public void DeleteManagedFile_OutsideRoot_Throws() + { + var outside = CreateTempFile(); + + try + { + Assert.Throws(() => _service.DeleteManagedFile(outside)); + Assert.True(File.Exists(outside)); + } + finally + { + File.Delete(outside); + } + } + + #endregion +} diff --git a/web/Areas/CMS/Constants/CmsFileTypes.cs b/web/Areas/CMS/Constants/CmsFileTypes.cs new file mode 100644 index 000000000..63cd94ead --- /dev/null +++ b/web/Areas/CMS/Constants/CmsFileTypes.cs @@ -0,0 +1,56 @@ +namespace Viper.Areas.CMS.Constants +{ + /// + /// Allowed CMS file extensions and their MIME types. Mirrors the legacy + /// ColdFusion Lookups.cfc allow-list; extensions not in this map are rejected on upload. + /// + public static class CmsFileTypes + { + public static readonly IReadOnlyDictionary MimeTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["pdf"] = "application/pdf", + ["docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ["doc"] = "application/msword", + ["xls"] = "application/vnd.ms-excel", + ["xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ["csv"] = "text/csv", + ["ppt"] = "application/vnd.ms-powerpoint", + ["pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ["pptm"] = "application/vnd.ms-powerpoint.presentation.macroEnabled.12", + ["txt"] = "text/plain", + ["html"] = "application/xhtml+xml", + ["gif"] = "image/gif", + ["png"] = "image/png", + ["jpg"] = "image/jpeg", + ["jpeg"] = "image/jpeg", + ["tiff"] = "image/tiff", + ["mp3"] = "audio/mpeg", + ["wav"] = "audio/wav", + ["mp4"] = "video/mp4", + ["webm"] = "video/webm", + ["oft"] = "application/vnd.ms-outlook", + ["eps"] = "application/postscript", + ["zip"] = "application/zip", + ["7z"] = "application/x-7z-compressed", + ["dmg"] = "application/x-apple-diskimage", + ["exe"] = "application/vnd.microsoft.portable-executable" + }; + + public static bool IsAllowedFileName(string fileName) + { + return MimeTypes.ContainsKey(GetExtension(fileName)); + } + + public static string GetMimeType(string fileName) + { + return MimeTypes.TryGetValue(GetExtension(fileName), out var mimeType) + ? mimeType + : "application/octet-stream"; + } + + private static string GetExtension(string fileName) + { + return Path.GetExtension(fileName).TrimStart('.'); + } + } +} diff --git a/web/Areas/CMS/Constants/CmsPermissions.cs b/web/Areas/CMS/Constants/CmsPermissions.cs new file mode 100644 index 000000000..4f3ede920 --- /dev/null +++ b/web/Areas/CMS/Constants/CmsPermissions.cs @@ -0,0 +1,15 @@ +namespace Viper.Areas.CMS.Constants +{ + /// + /// RAPS permission strings used by the CMS area. + /// + public static class CmsPermissions + { + public const string Base = "SVMSecure.CMS"; + public const string AllFiles = "SVMSecure.CMS.AllFiles"; + public const string ManageContentBlocks = "SVMSecure.CMS.ManageContentBlocks"; + public const string CreateContentBlock = "SVMSecure.CMS.CreateContentBlock"; + public const string ManageNavigation = "SVMSecure.CMS.ManageNavigation"; + public const string Admin = "SVMSecure.CATS.Admin"; + } +} diff --git a/web/Areas/CMS/Controllers/CMSFilesController.cs b/web/Areas/CMS/Controllers/CMSFilesController.cs new file mode 100644 index 000000000..a521b25a6 --- /dev/null +++ b/web/Areas/CMS/Controllers/CMSFilesController.cs @@ -0,0 +1,204 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; +using Viper.Classes; +using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +using Viper.Models; +using Viper.Models.VIPER; +using Web.Authorization; + +namespace Viper.Areas.CMS.Controllers +{ + /// + /// File management API (list/upload/edit/delete/restore/audit). Downloads are served by + /// the existing permission-checked CMSController (/CMS/Files), which legacy URLs point at. + /// + [Route("/api/cms/files")] + [Permission(Allow = CmsPermissions.AllFiles)] + public class CMSFilesController : ApiController + { + private const long MaxUploadBytes = 100_000_000; + + private readonly ICmsFileService _fileService; + private readonly ICmsFileStorageService _storage; + private readonly ICmsFileAuditService _auditService; + private readonly RAPSContext _rapsContext; + private readonly ILogger _logger; + private readonly IUserHelper _userHelper; + + public CMSFilesController(ICmsFileService fileService, ICmsFileStorageService storage, + ICmsFileAuditService auditService, RAPSContext rapsContext, ILogger logger, + IUserHelper userHelper) + { + _fileService = fileService; + _storage = storage; + _auditService = auditService; + _rapsContext = rapsContext; + _logger = logger; + _userHelper = userHelper; + } + + // GET /api/cms/files + [HttpGet] + [ApiPagination(DefaultPerPage = 50, MaxPerPage = 500)] + public async Task>> GetFiles( + string? folder, + string? search, + bool? encrypted, + bool? isPublic, + ApiPagination? pagination, + string status = "active", + string? sortBy = "friendlyName", + bool descending = false, + CancellationToken ct = default) + { + var page = pagination?.Page ?? 1; + var perPage = pagination?.PerPage ?? 50; + var (files, total) = await _fileService.GetFilesAsync(folder, status, search, encrypted, isPublic, + page, perPage, sortBy, descending, ct); + if (pagination != null) + { + pagination.TotalRecords = total; + } + return files; + } + + // GET /api/cms/files/folders + [HttpGet("folders")] + public ActionResult> GetFolders() + { + return _storage.GetTopLevelFolders(); + } + + // GET /api/cms/files/audit + [HttpGet("audit")] + [ApiPagination(DefaultPerPage = 50, MaxPerPage = 500)] + public async Task>> GetAudit( + Guid? fileGuid, + string? action, + string? loginId, + DateTime? from, + DateTime? to, + string? search, + ApiPagination? pagination, + CancellationToken ct = default) + { + var filter = new CmsFileAuditFilter + { + FileGuid = fileGuid, + Action = action, + LoginId = loginId, + From = from, + To = to, + Search = search + }; + var page = pagination?.Page ?? 1; + var perPage = pagination?.PerPage ?? 50; + var entries = await _auditService.GetAuditEntriesAsync(filter, page, perPage, ct); + if (pagination != null) + { + pagination.TotalRecords = await _auditService.GetAuditEntryCountAsync(filter, ct); + } + return entries; + } + + // GET /api/cms/files/{fileGuid} + [HttpGet("{fileGuid:guid}")] + public async Task> GetFile(Guid fileGuid, CancellationToken ct = default) + { + var file = await _fileService.GetFileAsync(fileGuid, ct); + if (file == null) + { + return NotFound(); + } + return file; + } + + // POST /api/cms/files + [HttpPost] + [RequestSizeLimit(MaxUploadBytes)] + [RequestFormLimits(MultipartBodyLengthLimit = MaxUploadBytes)] + public async Task> UploadFile([FromForm] CmsFileCreateRequest request, IFormFile? file, + CancellationToken ct = default) + { + if (file == null || file.Length == 0) + { + return BadRequest("A file is required."); + } + + try + { + return await _fileService.CreateFileAsync(request, file, ct); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + LogFileConflict(nameof(UploadFile), request.Folder, file.FileName, ex); + return BadRequest(ex.Message); + } + } + + // PUT /api/cms/files/{fileGuid} + [HttpPut("{fileGuid:guid}")] + [RequestSizeLimit(MaxUploadBytes)] + [RequestFormLimits(MultipartBodyLengthLimit = MaxUploadBytes)] + public async Task> UpdateFile(Guid fileGuid, [FromForm] CmsFileUpdateRequest request, + IFormFile? file, CancellationToken ct = default) + { + try + { + var updated = await _fileService.UpdateFileAsync(fileGuid, request, file, ct); + if (updated == null) + { + return NotFound(); + } + return updated; + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + LogFileConflict(nameof(UpdateFile), null, file?.FileName, ex); + return BadRequest(ex.Message); + } + } + + // DELETE /api/cms/files/{fileGuid}?permanent=true|false + [HttpDelete("{fileGuid:guid}")] + public async Task DeleteFile(Guid fileGuid, bool permanent = false, CancellationToken ct = default) + { + if (permanent) + { + // Hard delete is restricted to admins; soft delete is available to all file managers. + if (!_userHelper.HasPermission(_rapsContext, _userHelper.GetCurrentUser(), CmsPermissions.Admin)) + { + return ForbidApi(); + } + return await _fileService.PermanentlyDeleteFileAsync(fileGuid, ct) ? NoContent() : NotFound(); + } + return await _fileService.SoftDeleteFileAsync(fileGuid, ct) ? NoContent() : NotFound(); + } + + // POST /api/cms/files/{fileGuid}/restore + [HttpPost("{fileGuid:guid}/restore")] + public async Task RestoreFile(Guid fileGuid, CancellationToken ct = default) + { + return await _fileService.RestoreFileAsync(fileGuid, ct) ? Ok() : NotFound(); + } + + private void LogFileConflict(string operation, string? folder, string? fileName, Exception ex) + { + _logger.LogWarning(ex, "CMS file {Operation} conflict folder={Folder} fileName={FileName}", + LogSanitizer.SanitizeString(operation), + LogSanitizer.SanitizeString(folder), + LogSanitizer.SanitizeString(fileName)); + } + } +} diff --git a/web/Areas/CMS/Data/CMS.cs b/web/Areas/CMS/Data/CMS.cs index 1cf19d991..7569be092 100644 --- a/web/Areas/CMS/Data/CMS.cs +++ b/web/Areas/CMS/Data/CMS.cs @@ -1,6 +1,5 @@ using System.IO.Compression; using System.Net; -using System.Security.Cryptography; using System.Text.Json; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; @@ -27,35 +26,8 @@ public class CMS : ICMS public IUserHelper UserHelper { get; set; } - public Dictionary MimeTypes { get; set; } = new() - { - ["pdf"] = "application/pdf", - ["docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ["doc"] = "application/msword", - ["xls"] = "application/vnd.ms-excel", - ["xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ["csv"] = "text/csv", - ["ppt"] = "application/vnd.ms-powerpoint", - ["pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ["pptm"] = "application/vnd.ms-powerpoint.presentation.macroEnabled.12", - ["txt"] = "text/plain", - ["html"] = "application/xhtml+xml", - ["gif"] = "image/gif", - ["png"] = "image/png", - ["jpg"] = "image/jpeg", - ["jpeg"] = "image/jpeg", - ["tiff"] = "image/tiff", - ["mp3"] = "audio/mpeg", - ["wav"] = "audio/wav", - ["mp4"] = "video/mp4", - ["webm"] = "video/webm", - ["oft"] = "application/vnd.ms-outlook", - ["eps"] = "application/postscript", - ["zip"] = "application/zip", - ["7z"] = "application/x-7z-compressed", - ["dmg"] = "application/x-apple-diskimage", - ["exe"] = "application/vnd.microsoft.portable-executable" - }; + public Dictionary MimeTypes { get; set; } = + new(Constants.CmsFileTypes.MimeTypes, StringComparer.OrdinalIgnoreCase); #endregion @@ -664,81 +636,19 @@ public static void AuditFileAccess(VIPERContext viperContext, CMSFile file, Aaud #region public byte[] DecryptFile(byte[] encryptedData, string keystring) public byte[] DecryptFile(byte[] encryptedData, string keystring) { - byte[] secretkey = GetSecretKey(keystring); - - using Aes aes = Aes.Create(); - aes.Mode = CipherMode.ECB; - - using var ms = new MemoryStream(); - using (var cs = new CryptoStream(ms, aes.CreateDecryptor(secretkey, null), CryptoStreamMode.Write)) - { - cs.Write(encryptedData, 0, encryptedData.Length); - } - byte[] decryptedData = ms.ToArray(); - - return decryptedData; - + string masterKey = CmsFileCrypto.ReadMasterKey(CmsFileCrypto.GetDefaultKeyFilePath()); + return CmsFileCrypto.DecryptBytes(encryptedData, CmsFileCrypto.DecryptDbKey(keystring, masterKey)); } #endregion #region public string DecryptAES(string encryptedString, string Key) /// - /// Required for Unix decoding FROM https://rextester.com/TGN19503 + /// Decrypt a CF-format (UU-encoded AES-ECB) string with the given base64 key. /// /// decoded string public string DecryptAES(string encryptedString, string Key) { - //First write to memory - using MemoryStream mmsStream = new(); - using StreamWriter srwTemp = new(mmsStream); - srwTemp.Write(encryptedString); - srwTemp.Flush(); - mmsStream.Position = 0; - - using MemoryStream outstream = new(); - - //CallingUUDecode - Codecs.UUDecode(mmsStream, outstream); - - //Extract the bytes of each of the values - byte[] input = outstream.ToArray(); - byte[] key = Convert.FromBase64String(Key); - - string? decryptedText = null; - - using (Aes aes = Aes.Create()) - { - // initialize settings to match those used by CF - aes.Mode = CipherMode.ECB; - aes.Padding = PaddingMode.PKCS7; - aes.BlockSize = 128; - aes.KeySize = 128; - aes.Key = key; - - ICryptoTransform decryptor = aes.CreateDecryptor(); - - using MemoryStream msDecrypt = new(input); - using CryptoStream csDecrypt = new(msDecrypt, decryptor, CryptoStreamMode.Read); - using StreamReader srDecrypt = new(csDecrypt); - decryptedText = srDecrypt.ReadToEnd(); - } - return decryptedText; - } - #endregion - - #region private byte[] getSecretKey(string key) - private byte[] GetSecretKey(string key) - { - string keyFileFolder = @"S:\Settings\"; - - if (HttpHelper.Environment?.EnvironmentName == "Development") - { - keyFileFolder = @"C:\Sites\Settings\"; - } - string keyString = System.IO.File.ReadLines(keyFileFolder + "viperfiles.txt").Skip(1).Take(1).First(); - - byte[] hiddenKey = Convert.FromBase64String(DecryptAES(key, keyString)); - return hiddenKey; + return CmsFileCrypto.DecryptDbKey(encryptedString, Key); } #endregion diff --git a/web/Areas/CMS/Models/CmsFileMapper.cs b/web/Areas/CMS/Models/CmsFileMapper.cs new file mode 100644 index 000000000..dee8d8a71 --- /dev/null +++ b/web/Areas/CMS/Models/CmsFileMapper.cs @@ -0,0 +1,39 @@ +using Riok.Mapperly.Abstractions; +using Viper.Areas.CMS.Models.DTOs; +using File = Viper.Models.VIPER.File; + +namespace Viper.Areas.CMS.Models +{ + [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] + public static partial class CmsFileMapper + { + /// + /// Map a file entity to its DTO; permission/person collections and computed URLs are + /// filled in here rather than by the generated mapping. + /// + public static CmsFileDto ToCmsFileDto(File file, IReadOnlyDictionary? namesByIamId = null) + { + var dto = ToCmsFileDtoBase(file); + dto.FileName = Path.GetFileName(file.FilePath); + dto.Permissions = file.FileToPermissions.Select(p => p.Permission).OrderBy(p => p).ToList(); + dto.People = file.FileToPeople + .Select(p => new CmsFilePersonDto + { + IamId = p.IamId, + Name = namesByIamId != null && namesByIamId.TryGetValue(p.IamId, out var name) ? name : null + }) + .OrderBy(p => p.Name ?? p.IamId) + .ToList(); + dto.Url = Data.CMS.GetURL(file.FileGuid.ToString(), file.AllowPublicAccess); + dto.FriendlyUrl = Data.CMS.GetFriendlyURL(file.FriendlyName, file.AllowPublicAccess); + return dto; + } + + [MapperIgnoreTarget(nameof(CmsFileDto.FileName))] + [MapperIgnoreTarget(nameof(CmsFileDto.Permissions))] + [MapperIgnoreTarget(nameof(CmsFileDto.People))] + [MapperIgnoreTarget(nameof(CmsFileDto.Url))] + [MapperIgnoreTarget(nameof(CmsFileDto.FriendlyUrl))] + private static partial CmsFileDto ToCmsFileDtoBase(File file); + } +} diff --git a/web/Areas/CMS/Models/DTOs/CmsFileDto.cs b/web/Areas/CMS/Models/DTOs/CmsFileDto.cs new file mode 100644 index 000000000..c433e848a --- /dev/null +++ b/web/Areas/CMS/Models/DTOs/CmsFileDto.cs @@ -0,0 +1,27 @@ +namespace Viper.Areas.CMS.Models.DTOs +{ + public class CmsFileDto + { + public Guid FileGuid { get; set; } + public string FileName { get; set; } = string.Empty; + public string? Folder { get; set; } + public string FriendlyName { get; set; } = string.Empty; + public bool Encrypted { get; set; } + public string Description { get; set; } = string.Empty; + public bool AllowPublicAccess { get; set; } + public string? OldUrl { 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 People { get; set; } = new(); + public string Url { get; set; } = string.Empty; + public string FriendlyUrl { get; set; } = string.Empty; + } + + public class CmsFilePersonDto + { + public string IamId { get; set; } = string.Empty; + public string? Name { get; set; } + } +} diff --git a/web/Areas/CMS/Models/DTOs/CmsFileRequests.cs b/web/Areas/CMS/Models/DTOs/CmsFileRequests.cs new file mode 100644 index 000000000..3bab26700 --- /dev/null +++ b/web/Areas/CMS/Models/DTOs/CmsFileRequests.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; + +namespace Viper.Areas.CMS.Models.DTOs +{ + /// + /// Form fields accompanying a new file upload (multipart/form-data). + /// + public class CmsFileCreateRequest + { + [Required] + public string Folder { get; set; } = string.Empty; + + [MaxLength(1000)] + public string? Description { get; set; } + + public bool? AllowPublicAccess { get; set; } + + [MaxLength(256)] + public string? OldUrl { get; set; } + + public List Permissions { get; set; } = new(); + + public List IamIds { get; set; } = new(); + + public bool? Encrypt { get; set; } + + /// When true, auto-rename on name conflict (name_0..name_999) instead of failing. + public bool? MakeUnique { get; set; } + } + + /// + /// Form fields for editing file metadata; the file itself is an optional replacement upload. + /// + public class CmsFileUpdateRequest + { + [MaxLength(1000)] + public string? Description { get; set; } + + public bool? AllowPublicAccess { get; set; } + + [MaxLength(256)] + public string? OldUrl { get; set; } + + public List Permissions { get; set; } = new(); + + public List IamIds { get; set; } = new(); + + public bool? Encrypt { get; set; } + } +} diff --git a/web/Areas/CMS/Services/CmsFileAuditService.cs b/web/Areas/CMS/Services/CmsFileAuditService.cs new file mode 100644 index 000000000..f6de585b5 --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileAuditService.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Models; +using Viper.Classes.SQLContext; +using Viper.Models.VIPER; +using File = Viper.Models.VIPER.File; + +namespace Viper.Areas.CMS.Services +{ + /// + /// File audit actions, matching the legacy ColdFusion FileAudit.cfc action names so the + /// shared fileAudit table stays consistent across both systems. + /// + public static class CmsFileAuditActions + { + public const string UploadFile = "UploadFile"; + public const string AddFile = "AddFile"; + public const string EditFile = "EditFile"; + public const string DeleteFile = "DeleteFile"; + public const string CancelDelete = "CancelDelete"; + public const string ImportFile = "ImportFile"; + public const string AccessFile = "AccessFile"; + public const string AccessFileDenied = "AccessFileDenied"; + } + + public class CmsFileAuditFilter + { + public Guid? FileGuid { get; set; } + public string? Action { get; set; } + public string? LoginId { get; set; } + public DateTime? From { get; set; } + public DateTime? To { get; set; } + public string? Search { get; set; } + } + + public interface ICmsFileAuditService + { + /// Add an audit row for a file operation. Does not call SaveChanges. + void Audit(File file, string action, string detail = ""); + + Task> GetAuditEntriesAsync(CmsFileAuditFilter filter, int page, int perPage, CancellationToken ct = default); + + Task GetAuditEntryCountAsync(CmsFileAuditFilter filter, CancellationToken ct = default); + } + + public class CmsFileAuditService : ICmsFileAuditService + { + private readonly VIPERContext _context; + private readonly IUserHelper _userHelper; + + public CmsFileAuditService(VIPERContext context, IUserHelper userHelper) + { + _context = context; + _userHelper = userHelper; + } + + public void Audit(File file, string action, string detail = "") + { + var user = _userHelper.GetCurrentUser(); + var metaData = new CMSFileMetaData + { + Folder = file.Folder, + Encrypted = file.Encrypted, + Public = file.AllowPublicAccess, + Modified = file.ModifiedOn.ToString("yyyy-MM-dd HH:mm:ss"), + ModifiedBy = file.ModifiedBy + }; + + _context.FileAudits.Add(new FileAudit + { + Timestamp = DateTime.Now, + Loginid = user?.LoginId, + Action = action, + Detail = detail, + FileGuid = file.FileGuid, + FilePath = file.FilePath, + IamId = user?.IamId, + FileMetaData = JsonSerializer.Serialize(metaData), + ClientData = JsonSerializer.Serialize(_userHelper.GetClientData()) + }); + } + + public async Task> GetAuditEntriesAsync(CmsFileAuditFilter filter, int page, int perPage, CancellationToken ct = default) + { + return await BuildQuery(filter) + .OrderByDescending(a => a.Timestamp) + .ThenByDescending(a => a.AuditId) + .Skip((page - 1) * perPage) + .Take(perPage) + .ToListAsync(ct); + } + + public async Task GetAuditEntryCountAsync(CmsFileAuditFilter filter, CancellationToken ct = default) + { + return await BuildQuery(filter).CountAsync(ct); + } + + private IQueryable BuildQuery(CmsFileAuditFilter filter) + { + var query = _context.FileAudits.AsNoTracking(); + if (filter.FileGuid != null) + { + query = query.Where(a => a.FileGuid == filter.FileGuid); + } + if (!string.IsNullOrEmpty(filter.Action)) + { + query = query.Where(a => a.Action == filter.Action); + } + if (!string.IsNullOrEmpty(filter.LoginId)) + { + query = query.Where(a => a.Loginid == filter.LoginId); + } + if (filter.From != null) + { + query = query.Where(a => a.Timestamp >= filter.From); + } + if (filter.To != null) + { + // Treat the To date as inclusive through end of day. + var to = filter.To.Value.Date == filter.To.Value ? filter.To.Value.AddDays(1) : filter.To.Value; + query = query.Where(a => a.Timestamp < to); + } + if (!string.IsNullOrEmpty(filter.Search)) + { + query = query.Where(a => (a.FilePath != null && a.FilePath.Contains(filter.Search)) + || (a.Detail != null && a.Detail.Contains(filter.Search))); + } + return query; + } + } +} diff --git a/web/Areas/CMS/Services/CmsFileCrypto.cs b/web/Areas/CMS/Services/CmsFileCrypto.cs new file mode 100644 index 000000000..d047405d1 --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileCrypto.cs @@ -0,0 +1,121 @@ +using System.Security.Cryptography; +using System.Text; +using Viper.Areas.CMS.Data; + +namespace Viper.Areas.CMS.Services +{ + /// + /// CMS file encryption primitives, byte-for-byte compatible with the legacy ColdFusion CMS + /// (CF encrypt()/encryptBinary() with algorithm "AES" = AES-128-ECB-PKCS7, key strings UU-encoded). + /// Both systems share the same files and database during the migration, so the format cannot + /// change until ColdFusion is decommissioned (see PLAN-CMS.md §12.6 for the GCM migration plan). + /// + /// Key model: + /// - The master key is a base64 string on line 2 of viperfiles.txt. + /// - Each file has its own random AES-128 key. The DB "key" column stores + /// UUEncode(AES-ECB(masterKey, base64(fileKey))). + /// - File contents are AES-ECB encrypted with the per-file key. + /// + public static class CmsFileCrypto + { + /// + /// Default location of the master key file for the current environment. + /// + public static string GetDefaultKeyFilePath() + { + string settingsFolder = HttpHelper.Environment?.EnvironmentName == "Development" + ? @"C:\Sites\Settings" + : @"S:\Settings"; + return Path.Join(settingsFolder, "viperfiles.txt"); + } + + /// + /// Read the master key (base64 string) from line 2 of the key file. + /// + public static string ReadMasterKey(string keyFilePath) + { + string? masterKey = System.IO.File.ReadLines(keyFilePath).Skip(1).FirstOrDefault(); + if (string.IsNullOrWhiteSpace(masterKey)) + { + throw new InvalidOperationException("CMS master key file is missing the key line."); + } + return masterKey.Trim(); + } + + /// + /// Generate a new random per-file AES-128 key, returned as base64 (the format CF + /// generateSecretKey("AES") produced). + /// + public static string GenerateFileKey() + { + return Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + } + + /// + /// Encrypt a per-file key for storage in the files.key column: + /// UUEncode(AES-ECB(masterKey, UTF8(fileKeyBase64))). + /// + public static string EncryptDbKey(string fileKeyBase64, string masterKeyBase64) + { + byte[] cipher = EncryptEcb(Encoding.UTF8.GetBytes(fileKeyBase64), Convert.FromBase64String(masterKeyBase64)); + using MemoryStream input = new(cipher); + using MemoryStream output = new(); + Codecs.UUEncode(input, output); + return Encoding.ASCII.GetString(output.ToArray()); + } + + /// + /// Decrypt a files.key column value back to the per-file key (base64 string). + /// Mirrors legacy CF decrypt(key, masterKey, "AES") with default UU encoding. + /// + public static string DecryptDbKey(string dbKey, string masterKeyBase64) + { + using MemoryStream input = new(Encoding.ASCII.GetBytes(dbKey)); + using MemoryStream decoded = new(); + Codecs.UUDecode(input, decoded); + byte[] plain = DecryptEcb(decoded.ToArray(), Convert.FromBase64String(masterKeyBase64)); + return Encoding.UTF8.GetString(plain); + } + + /// + /// Encrypt file contents with a per-file key (base64), matching CF encryptBinary(data, key, "AES"). + /// + public static byte[] EncryptBytes(byte[] data, string fileKeyBase64) + { + return EncryptEcb(data, Convert.FromBase64String(fileKeyBase64)); + } + + /// + /// Decrypt file contents with a per-file key (base64), matching CF decryptBinary(data, key, "AES"). + /// + public static byte[] DecryptBytes(byte[] data, string fileKeyBase64) + { + return DecryptEcb(data, Convert.FromBase64String(fileKeyBase64)); + } + + private static byte[] EncryptEcb(byte[] data, byte[] key) + { + using Aes aes = CreateCfCompatibleAes(key); + using ICryptoTransform encryptor = aes.CreateEncryptor(); + return encryptor.TransformFinalBlock(data, 0, data.Length); + } + + private static byte[] DecryptEcb(byte[] data, byte[] key) + { + using Aes aes = CreateCfCompatibleAes(key); + using ICryptoTransform decryptor = aes.CreateDecryptor(); + return decryptor.TransformFinalBlock(data, 0, data.Length); + } + + private static Aes CreateCfCompatibleAes(byte[] key) + { + Aes aes = Aes.Create(); + // Settings match ColdFusion's "AES" algorithm defaults; ECB is required for + // compatibility with existing encrypted files (GCM migration is planned post-CF). + aes.Mode = CipherMode.ECB; + aes.Padding = PaddingMode.PKCS7; + aes.Key = key; + return aes; + } + } +} diff --git a/web/Areas/CMS/Services/CmsFileEncryptionService.cs b/web/Areas/CMS/Services/CmsFileEncryptionService.cs new file mode 100644 index 000000000..211512380 --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileEncryptionService.cs @@ -0,0 +1,99 @@ +namespace Viper.Areas.CMS.Services +{ + public interface ICmsFileEncryptionService + { + /// + /// Generate a new per-file key, returned in the encrypted form stored in files.key. + /// + string GenerateKeyForDb(); + + byte[] Encrypt(byte[] data, string dbKey); + + byte[] Decrypt(byte[] data, string dbKey); + + void EncryptFileInPlace(string filePath, string dbKey); + + void DecryptFileInPlace(string filePath, string dbKey); + } + + /// + /// DI wrapper around that resolves and caches the master key. + /// Registered by the Scrutor convention scan of Viper.Areas.CMS.Services. + /// + public class CmsFileEncryptionService : ICmsFileEncryptionService + { + private readonly Lazy _masterKey; + + public CmsFileEncryptionService(IConfiguration configuration) + { + string keyFilePath = configuration["CMS:EncryptionKeyFile"] ?? CmsFileCrypto.GetDefaultKeyFilePath(); + _masterKey = new Lazy(() => CmsFileCrypto.ReadMasterKey(keyFilePath)); + } + + public string GenerateKeyForDb() + { + return CmsFileCrypto.EncryptDbKey(CmsFileCrypto.GenerateFileKey(), _masterKey.Value); + } + + public byte[] Encrypt(byte[] data, string dbKey) + { + return CmsFileCrypto.EncryptBytes(data, CmsFileCrypto.DecryptDbKey(dbKey, _masterKey.Value)); + } + + public byte[] Decrypt(byte[] data, string dbKey) + { + return CmsFileCrypto.DecryptBytes(data, CmsFileCrypto.DecryptDbKey(dbKey, _masterKey.Value)); + } + + public void EncryptFileInPlace(string filePath, string dbKey) + { + ReplaceFileContents(filePath, contents => Encrypt(contents, dbKey)); + } + + public void DecryptFileInPlace(string filePath, string dbKey) + { + ReplaceFileContents(filePath, contents => Decrypt(contents, dbKey)); + } + + /// + /// Rewrite a file via a temp file in the same directory so an interrupted write + /// can't leave the target truncated or half-transformed. + /// + private static void ReplaceFileContents(string filePath, Func transform) + { + byte[] contents = System.IO.File.ReadAllBytes(filePath); + string tempPath = filePath + ".tmp"; + try + { + System.IO.File.WriteAllBytes(tempPath, transform(contents)); + System.IO.File.Move(tempPath, filePath, overwrite: true); + } + catch (IOException) + { + CleanUpTempFile(tempPath); + throw; + } + catch (UnauthorizedAccessException) + { + CleanUpTempFile(tempPath); + throw; + } + } + + private static void CleanUpTempFile(string tempPath) + { + try + { + System.IO.File.Delete(tempPath); + } + catch (IOException) + { + // Best effort; the orphaned .tmp file is harmless and the original error matters more. + } + catch (UnauthorizedAccessException) + { + // Best effort; see above. + } + } + } +} diff --git a/web/Areas/CMS/Services/CmsFileService.cs b/web/Areas/CMS/Services/CmsFileService.cs new file mode 100644 index 000000000..e2d41ffcf --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileService.cs @@ -0,0 +1,478 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Models; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Classes.SQLContext; +using File = Viper.Models.VIPER.File; + +namespace Viper.Areas.CMS.Services +{ + public interface ICmsFileService + { + Task<(List Files, int Total)> GetFilesAsync(string? folder, string status, string? search, + bool? encrypted, bool? isPublic, int page, int perPage, string? sortBy, bool descending, CancellationToken ct = default); + + Task GetFileAsync(Guid fileGuid, CancellationToken ct = default); + + Task CreateFileAsync(CmsFileCreateRequest request, IFormFile file, CancellationToken ct = default); + + Task UpdateFileAsync(Guid fileGuid, CmsFileUpdateRequest request, IFormFile? file, CancellationToken ct = default); + + Task SoftDeleteFileAsync(Guid fileGuid, CancellationToken ct = default); + + Task RestoreFileAsync(Guid fileGuid, CancellationToken ct = default); + + Task PermanentlyDeleteFileAsync(Guid fileGuid, CancellationToken ct = default); + } + + /// + /// Management operations for CMS files (the admin side; downloads are served by + /// CMSController/Data.CMS). Behavior mirrors the legacy ColdFusion Files.cfc: + /// friendly names are folder-filename with backslashes dashed, permission and person + /// lists are replaced as deltas, and every operation writes a fileAudit row. + /// + public class CmsFileService : ICmsFileService + { + private readonly VIPERContext _context; + private readonly AAUDContext _aaudContext; + private readonly ICmsFileStorageService _storage; + private readonly ICmsFileEncryptionService _encryption; + private readonly ICmsFileAuditService _audit; + private readonly IUserHelper _userHelper; + + public CmsFileService(VIPERContext context, AAUDContext aaudContext, ICmsFileStorageService storage, + ICmsFileEncryptionService encryption, ICmsFileAuditService audit, IUserHelper userHelper) + { + _context = context; + _aaudContext = aaudContext; + _storage = storage; + _encryption = encryption; + _audit = audit; + _userHelper = userHelper; + } + + public async Task<(List Files, int Total)> GetFilesAsync(string? folder, string status, string? search, + bool? encrypted, bool? isPublic, int page, int perPage, string? sortBy, bool descending, CancellationToken ct = default) + { + var query = _context.Files + .AsNoTracking() + .Include(f => f.FileToPermissions) + .Include(f => f.FileToPeople) + .AsSplitQuery() + .TagWith("CmsFileService.GetFiles") + .AsQueryable(); + + if (!string.IsNullOrEmpty(folder)) + { + // Folder may have subfolders stored as "folder\sub"; match the top-level folder. + var folderPrefix = folder + @"\"; + query = query.Where(f => f.Folder == folder || (f.Folder != null && f.Folder.StartsWith(folderPrefix))); + } + + query = status.ToLowerInvariant() switch + { + "active" => query.Where(f => f.DeletedOn == null), + "deleted" => query.Where(f => f.DeletedOn != null), + _ => query + }; + + if (encrypted != null) + { + query = query.Where(f => f.Encrypted == encrypted); + } + + if (isPublic != null) + { + query = query.Where(f => f.AllowPublicAccess == isPublic); + } + + if (!string.IsNullOrEmpty(search)) + { + query = query.Where(f => f.FriendlyName.Contains(search) + || f.Description.Contains(search) + || (f.OldUrl != null && f.OldUrl.Contains(search)) + || f.FilePath.Contains(search)); + } + + int total = await query.CountAsync(ct); + + query = (sortBy?.ToLowerInvariant(), descending) switch + { + ("folder", false) => query.OrderBy(f => f.Folder).ThenBy(f => f.FriendlyName), + ("folder", true) => query.OrderByDescending(f => f.Folder).ThenBy(f => f.FriendlyName), + ("modifiedon", false) => query.OrderBy(f => f.ModifiedOn), + ("modifiedon", true) => query.OrderByDescending(f => f.ModifiedOn), + ("oldurl", false) => query.OrderBy(f => f.OldUrl), + ("oldurl", true) => query.OrderByDescending(f => f.OldUrl), + (_, true) => query.OrderByDescending(f => f.FriendlyName), + _ => query.OrderBy(f => f.FriendlyName) + }; + + var files = await query + .Skip((page - 1) * perPage) + .Take(perPage) + .ToListAsync(ct); + + var names = await GetNamesByIamIdAsync(files.SelectMany(f => f.FileToPeople.Select(p => p.IamId)), ct); + return (files.Select(f => CmsFileMapper.ToCmsFileDto(f, names)).ToList(), total); + } + + public async Task GetFileAsync(Guid fileGuid, CancellationToken ct = default) + { + var file = await LoadFileAsync(fileGuid, tracking: false, ct); + if (file == null) + { + return null; + } + var names = await GetNamesByIamIdAsync(file.FileToPeople.Select(p => p.IamId), ct); + return CmsFileMapper.ToCmsFileDto(file, names); + } + + public async Task CreateFileAsync(CmsFileCreateRequest request, IFormFile file, CancellationToken ct = default) + { + if (!_storage.IsValidFolder(request.Folder)) + { + throw new ArgumentException("Invalid folder."); + } + if (!CmsFileTypes.IsAllowedFileName(file.FileName)) + { + throw new ArgumentException("File type is not allowed."); + } + + bool encrypt = request.Encrypt ?? false; + string? dbKey = encrypt ? _encryption.GenerateKeyForDb() : null; + + string tempPath = await _storage.SaveToTempAsync(file, ct); + string finalPath; + try + { + if (dbKey != null) + { + _encryption.EncryptFileInPlace(tempPath, dbKey); + } + finalPath = _storage.MoveIntoPlace(tempPath, request.Folder, file.FileName, request.MakeUnique ?? false); + } + finally + { + if (System.IO.File.Exists(tempPath)) + { + System.IO.File.Delete(tempPath); + } + } + + string friendlyName = BuildFriendlyName(request.Folder, Path.GetFileName(finalPath)); + if (await _context.Files.AnyAsync(f => f.FriendlyName == friendlyName, ct)) + { + _storage.DeleteManagedFile(finalPath); + throw new InvalidOperationException($"A file with the name {friendlyName} already exists."); + } + + var entity = new File + { + FileGuid = Guid.NewGuid(), + FilePath = finalPath, + Folder = request.Folder, + FriendlyName = friendlyName, + Encrypted = encrypt, + Key = dbKey, + Description = request.Description ?? string.Empty, + AllowPublicAccess = request.AllowPublicAccess ?? false, + OldUrl = NullIfEmpty(request.OldUrl), + ModifiedOn = DateTime.Now, + ModifiedBy = CurrentLoginId() + }; + + foreach (var permission in CleanList(request.Permissions)) + { + entity.FileToPermissions.Add(new Viper.Models.VIPER.FileToPermission { FileGuid = entity.FileGuid, Permission = permission }); + } + foreach (var iamId in CleanList(request.IamIds)) + { + entity.FileToPeople.Add(new Viper.Models.VIPER.FileToPerson { FileGuid = entity.FileGuid, IamId = iamId }); + } + + _context.Files.Add(entity); + _audit.Audit(entity, CmsFileAuditActions.AddFile, BuildCreateDetail(entity)); + _audit.Audit(entity, CmsFileAuditActions.UploadFile, "NewFile"); + await _context.SaveChangesAsync(ct); + + var names = await GetNamesByIamIdAsync(entity.FileToPeople.Select(p => p.IamId), ct); + return CmsFileMapper.ToCmsFileDto(entity, names); + } + + public async Task UpdateFileAsync(Guid fileGuid, CmsFileUpdateRequest request, IFormFile? file, CancellationToken ct = default) + { + var entity = await LoadFileAsync(fileGuid, tracking: true, ct); + if (entity == null) + { + return null; + } + + var changes = new List(); + bool encrypt = request.Encrypt ?? false; + bool allowPublicAccess = request.AllowPublicAccess ?? false; + + // Encryption toggle happens before any file replacement so the replacement upload + // is written with the file's final encryption state. + if (encrypt && !entity.Encrypted) + { + string dbKey = _encryption.GenerateKeyForDb(); + if (_storage.ManagedFileExists(entity.FilePath)) + { + _encryption.EncryptFileInPlace(entity.FilePath, dbKey); + } + entity.Key = dbKey; + entity.Encrypted = true; + changes.Add("Encrypted file"); + } + else if (!encrypt && entity.Encrypted) + { + if (!string.IsNullOrEmpty(entity.Key) && _storage.ManagedFileExists(entity.FilePath)) + { + _encryption.DecryptFileInPlace(entity.FilePath, entity.Key); + } + entity.Key = null; + entity.Encrypted = false; + changes.Add("Decrypted file"); + } + + string newDescription = request.Description ?? string.Empty; + if (entity.Description != newDescription) + { + entity.Description = newDescription; + changes.Add("Description updated"); + } + if (entity.AllowPublicAccess != allowPublicAccess) + { + entity.AllowPublicAccess = allowPublicAccess; + changes.Add($"Public access changed to {allowPublicAccess}"); + } + string? newOldUrl = NullIfEmpty(request.OldUrl); + if (entity.OldUrl != newOldUrl) + { + entity.OldUrl = newOldUrl; + changes.Add("Old URL updated"); + } + + ApplyPermissionDeltas(entity, CleanList(request.Permissions), changes); + ApplyPersonDeltas(entity, CleanList(request.IamIds), changes); + + if (file != null) + { + if (!CmsFileTypes.IsAllowedFileName(file.FileName)) + { + throw new ArgumentException("File type is not allowed."); + } + string tempPath = await _storage.SaveToTempAsync(file, ct); + try + { + if (entity.Encrypted && !string.IsNullOrEmpty(entity.Key)) + { + _encryption.EncryptFileInPlace(tempPath, entity.Key); + } + _storage.ReplaceInPlace(tempPath, entity.FilePath); + } + finally + { + if (System.IO.File.Exists(tempPath)) + { + System.IO.File.Delete(tempPath); + } + } + _audit.Audit(entity, CmsFileAuditActions.UploadFile, "ReplacingFile"); + } + + entity.ModifiedOn = DateTime.Now; + entity.ModifiedBy = CurrentLoginId(); + + _audit.Audit(entity, CmsFileAuditActions.EditFile, string.Join("; ", changes)); + await _context.SaveChangesAsync(ct); + + var names = await GetNamesByIamIdAsync(entity.FileToPeople.Select(p => p.IamId), ct); + return CmsFileMapper.ToCmsFileDto(entity, names); + } + + public async Task SoftDeleteFileAsync(Guid fileGuid, CancellationToken ct = default) + { + var entity = await LoadFileAsync(fileGuid, tracking: true, ct); + if (entity == null) + { + return false; + } + entity.DeletedOn = DateTime.Now; + entity.ModifiedOn = DateTime.Now; + entity.ModifiedBy = CurrentLoginId(); + _audit.Audit(entity, CmsFileAuditActions.DeleteFile, "File Marked for Deletion"); + await _context.SaveChangesAsync(ct); + return true; + } + + public async Task RestoreFileAsync(Guid fileGuid, CancellationToken ct = default) + { + var entity = await LoadFileAsync(fileGuid, tracking: true, ct); + if (entity == null) + { + return false; + } + entity.DeletedOn = null; + entity.ModifiedOn = DateTime.Now; + entity.ModifiedBy = CurrentLoginId(); + _audit.Audit(entity, CmsFileAuditActions.CancelDelete, "Delete cancelled"); + await _context.SaveChangesAsync(ct); + return true; + } + + public async Task PermanentlyDeleteFileAsync(Guid fileGuid, CancellationToken ct = default) + { + var entity = await LoadFileAsync(fileGuid, tracking: true, ct); + if (entity == null) + { + return false; + } + + _audit.Audit(entity, CmsFileAuditActions.DeleteFile, "File Deleted"); + _context.RemoveRange(entity.FileToPermissions); + _context.RemoveRange(entity.FileToPeople); + _context.Remove(entity); + await _context.SaveChangesAsync(ct); + + if (_storage.ManagedFileExists(entity.FilePath)) + { + _storage.DeleteManagedFile(entity.FilePath); + } + return true; + } + + private async Task LoadFileAsync(Guid fileGuid, bool tracking, CancellationToken ct) + { + var query = _context.Files + .Include(f => f.FileToPermissions) + .Include(f => f.FileToPeople) + .AsSplitQuery(); + if (!tracking) + { + query = query.AsNoTracking(); + } + var file = await query.FirstOrDefaultAsync(f => f.FileGuid == fileGuid, ct); + if (file != null) + { + file.FilePath = NormalizeRootFolder(file.FilePath); + } + return file; + } + + /// + /// Fix up the storage root if the record was created on another environment + /// (e.g. a dev path on prod), mirroring Data.CMS.ReplaceRootFolder but guarded + /// against paths that don't contain a \Files segment. + /// + private string NormalizeRootFolder(string filePath) + { + string root = _storage.RootFolder; + if (!filePath.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + { + int filesIndex = filePath.IndexOf(@"\Files\", StringComparison.OrdinalIgnoreCase); + if (filesIndex >= 0) + { + return root + filePath[(filesIndex + @"\Files".Length)..]; + } + } + return filePath; + } + + private void ApplyPermissionDeltas(File entity, List requested, List changes) + { + var existing = entity.FileToPermissions.ToList(); + foreach (var fp in existing.Where(fp => !requested.Contains(fp.Permission, StringComparer.OrdinalIgnoreCase))) + { + entity.FileToPermissions.Remove(fp); + _context.Remove(fp); + changes.Add($"Permission removed: {fp.Permission}"); + } + var existingNames = existing.Select(p => p.Permission).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var permission in requested.Where(p => !existingNames.Contains(p))) + { + entity.FileToPermissions.Add(new Viper.Models.VIPER.FileToPermission { FileGuid = entity.FileGuid, Permission = permission }); + changes.Add($"Permission added: {permission}"); + } + } + + private void ApplyPersonDeltas(File entity, List requested, List changes) + { + var existing = entity.FileToPeople.ToList(); + foreach (var fp in existing.Where(fp => !requested.Contains(fp.IamId, StringComparer.OrdinalIgnoreCase))) + { + entity.FileToPeople.Remove(fp); + _context.Remove(fp); + changes.Add($"Person removed: {fp.IamId}"); + } + var existingIds = existing.Select(p => p.IamId).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var iamId in requested.Where(p => !existingIds.Contains(p))) + { + entity.FileToPeople.Add(new Viper.Models.VIPER.FileToPerson { FileGuid = entity.FileGuid, IamId = iamId }); + changes.Add($"Person added: {iamId}"); + } + } + + private async Task> GetNamesByIamIdAsync(IEnumerable iamIds, CancellationToken ct) + { + var ids = iamIds.Distinct().ToList(); + if (ids.Count == 0) + { + return new Dictionary(); + } + return await _aaudContext.AaudUsers + .AsNoTracking() + .Where(u => u.IamId != null && EF.Parameter(ids).Contains(u.IamId)) + .GroupBy(u => u.IamId!) + .Select(g => new { IamId = g.Key, Name = g.Select(u => u.DisplayFullName).First() }) + .ToDictionaryAsync(x => x.IamId, x => x.Name, ct); + } + + private string CurrentLoginId() + { + return _userHelper.GetCurrentUser()?.LoginId ?? "unknown"; + } + + private static string BuildFriendlyName(string folder, string fileName) + { + return folder.Replace('\\', '-').Replace('/', '-') + "-" + fileName; + } + + private static string BuildCreateDetail(File entity) + { + var parts = new List { $"Folder: {entity.Folder}" }; + if (entity.AllowPublicAccess) + { + parts.Add("Public access: true"); + } + if (entity.Encrypted) + { + parts.Add("Encrypted: true"); + } + if (entity.FileToPermissions.Count > 0) + { + parts.Add("Permissions: " + string.Join(", ", entity.FileToPermissions.Select(p => p.Permission))); + } + if (entity.FileToPeople.Count > 0) + { + parts.Add("People: " + string.Join(", ", entity.FileToPeople.Select(p => p.IamId))); + } + return string.Join("; ", parts); + } + + private static List CleanList(List values) + { + return values + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string? NullIfEmpty(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + } +} diff --git a/web/Areas/CMS/Services/CmsFileStorageService.cs b/web/Areas/CMS/Services/CmsFileStorageService.cs new file mode 100644 index 000000000..f2575e611 --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileStorageService.cs @@ -0,0 +1,204 @@ +using Viper.Areas.CMS.Constants; +using Viper.Classes.SQLContext; + +namespace Viper.Areas.CMS.Services +{ + public interface ICmsFileStorageService + { + string RootFolder { get; } + + /// Top-level folders ("VIPER apps") that files can be stored under. + List GetTopLevelFolders(); + + /// + /// Validate a user-supplied folder (may contain subfolders, e.g. "cats\photos"). + /// The first segment must be an existing top-level folder and the resolved path + /// must stay inside the root. + /// + bool IsValidFolder(string folder); + + /// True if the name exists on disk or in the files table for this folder. + bool FileNameInUse(string folder, string fileName); + + /// Save an upload to a temp file (outside the storage root); returns the temp path. + Task SaveToTempAsync(IFormFile file, CancellationToken ct = default); + + /// + /// Move a temp file into the storage root at folder\fileName. On name conflict, either + /// auto-rename (name_0..name_999) or throw, per . + /// Returns the final absolute path. + /// + string MoveIntoPlace(string tempPath, string folder, string fileName, bool makeUnique); + + /// Overwrite the managed file at with a temp file. + void ReplaceInPlace(string tempPath, string existingFilePath); + + /// Delete a file, verifying it lives under the storage root first. + void DeleteManagedFile(string filePath); + + bool ManagedFileExists(string filePath); + } + + /// + /// On-disk storage for CMS-managed files. User input never becomes a path without + /// validation: folders are checked against the top-level folder list and resolved-path + /// containment, and file names are stripped to their final segment (see PLAN-CMS.md §11.7). + /// + public class CmsFileStorageService : ICmsFileStorageService + { + private readonly VIPERContext _context; + + public string RootFolder { get; } + + public CmsFileStorageService(VIPERContext context, IConfiguration configuration) + { + _context = context; + RootFolder = configuration["CMS:FileStorageRoot"] ?? Data.CMS.GetRootFileFolder(); + } + + public List GetTopLevelFolders() + { + if (!System.IO.Directory.Exists(RootFolder)) + { + return new List(); + } + return System.IO.Directory.GetDirectories(RootFolder) + .Select(d => Path.GetFileName(d) ?? string.Empty) + .Where(d => !string.IsNullOrEmpty(d)) + .OrderBy(d => d) + .ToList(); + } + + public bool IsValidFolder(string folder) + { + if (string.IsNullOrWhiteSpace(folder)) + { + return false; + } + + var segments = folder.Split(['\\', '/'], StringSplitOptions.None); + if (segments.Any(s => string.IsNullOrWhiteSpace(s) || s == "." || s == ".." + || s.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)) + { + return false; + } + + // First segment must be an existing top-level folder (legacy rule); subfolders + // may be created on demand by MoveIntoPlace. + if (!GetTopLevelFolders().Any(f => string.Equals(f, segments[0], StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return IsUnderRoot(Path.Join(RootFolder, Path.Join(segments))); + } + + public bool FileNameInUse(string folder, string fileName) + { + string targetPath = BuildTargetPath(folder, fileName); + if (System.IO.File.Exists(targetPath)) + { + return true; + } + // Also check the DB in case a record exists whose disk file is missing; a new file + // at that path would be served under the orphaned record's permissions. + return _context.Files.Any(f => f.FilePath == targetPath); + } + + public async Task SaveToTempAsync(IFormFile file, CancellationToken ct = default) + { + string tempFolder = Path.Join(Path.GetTempPath(), "Viper-CMS-Uploads"); + System.IO.Directory.CreateDirectory(tempFolder); + string tempPath = Path.Join(tempFolder, Guid.NewGuid().ToString("N")); + await using FileStream stream = new(tempPath, FileMode.CreateNew); + await file.CopyToAsync(stream, ct); + return tempPath; + } + + public string MoveIntoPlace(string tempPath, string folder, string fileName, bool makeUnique) + { + if (!IsValidFolder(folder)) + { + throw new ArgumentException($"Invalid folder", nameof(folder)); + } + + string finalName = Path.GetFileName(fileName); + if (string.IsNullOrWhiteSpace(finalName) || !CmsFileTypes.IsAllowedFileName(finalName)) + { + throw new ArgumentException("Invalid file name", nameof(fileName)); + } + + if (FileNameInUse(folder, finalName)) + { + if (!makeUnique) + { + throw new InvalidOperationException($"File {finalName} already exists in folder {folder}."); + } + finalName = GetUniqueFileName(folder, finalName); + } + + string targetPath = BuildTargetPath(folder, finalName); + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + System.IO.File.Move(tempPath, targetPath); + return targetPath; + } + + public void ReplaceInPlace(string tempPath, string existingFilePath) + { + AssertUnderRoot(existingFilePath); + System.IO.File.Move(tempPath, existingFilePath, overwrite: true); + } + + public void DeleteManagedFile(string filePath) + { + AssertUnderRoot(filePath); + if (System.IO.File.Exists(filePath)) + { + System.IO.File.Delete(filePath); + } + } + + public bool ManagedFileExists(string filePath) + { + return IsUnderRoot(filePath) && System.IO.File.Exists(filePath); + } + + private string GetUniqueFileName(string folder, string fileName) + { + string baseName = Path.GetFileNameWithoutExtension(fileName); + string extension = Path.GetExtension(fileName); + // Match legacy behavior: append _0, _1, ... _999 until unique. + for (int i = 0; i < 1000; i++) + { + string candidate = $"{baseName}_{i}{extension}"; + if (!FileNameInUse(folder, candidate)) + { + return candidate; + } + } + throw new InvalidOperationException($"Unable to generate a unique name for {fileName} in {folder}."); + } + + private string BuildTargetPath(string folder, string fileName) + { + string path = Path.GetFullPath(Path.Join(RootFolder, folder, Path.GetFileName(fileName))); + AssertUnderRoot(path); + return path; + } + + private void AssertUnderRoot(string filePath) + { + if (!IsUnderRoot(filePath)) + { + throw new ArgumentException("Path is outside the CMS file storage root.", nameof(filePath)); + } + } + + private bool IsUnderRoot(string filePath) + { + string fullPath = Path.GetFullPath(filePath); + string root = Path.GetFullPath(RootFolder + Path.DirectorySeparatorChar); + return fullPath.StartsWith(root, StringComparison.OrdinalIgnoreCase); + } + } +} From b0962a0b8c67045f253b3341ed02e46bb501cc66 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 11 Jun 2026 00:59:16 -0700 Subject: [PATCH 02/40] VPR-59 feat(cms): add file management UI - Files page at /CMS/ManageFiles (not /CMS/Files, which is the MVC download handler): server-paged list with folder/status/search/ encrypted filters, upload and edit dialogs, soft delete/restore - Shared PermissionSelector and PersonSelector backed by new /api/cms/options endpoints, since the RAPS permission list requires RAPS roles + 2FA and Directory search is too heavy for type-ahead - File audit log page with action/user/date/search filters and per-file filtering via ?fileGuid= - CMS home cards shown per user permission - ViperFetch gains postForm/putForm for multipart uploads --- VueApp/src/CMS/components/FileFormDialog.vue | 292 ++++++++++++ .../src/CMS/components/PermissionSelector.vue | 61 +++ VueApp/src/CMS/components/PersonSelector.vue | 81 ++++ VueApp/src/CMS/pages/CmsHome.vue | 75 +++- VueApp/src/CMS/pages/FileAuditLog.vue | 233 ++++++++++ VueApp/src/CMS/pages/Files.vue | 415 ++++++++++++++++++ VueApp/src/CMS/router/routes.ts | 14 + VueApp/src/CMS/types/index.ts | 57 ++- VueApp/src/composables/ViperFetch.ts | 20 +- .../CMS/Controllers/CMSOptionsController.cs | 84 ++++ 10 files changed, 1327 insertions(+), 5 deletions(-) create mode 100644 VueApp/src/CMS/components/FileFormDialog.vue create mode 100644 VueApp/src/CMS/components/PermissionSelector.vue create mode 100644 VueApp/src/CMS/components/PersonSelector.vue create mode 100644 VueApp/src/CMS/pages/FileAuditLog.vue create mode 100644 VueApp/src/CMS/pages/Files.vue create mode 100644 web/Areas/CMS/Controllers/CMSOptionsController.cs diff --git a/VueApp/src/CMS/components/FileFormDialog.vue b/VueApp/src/CMS/components/FileFormDialog.vue new file mode 100644 index 000000000..adaa42864 --- /dev/null +++ b/VueApp/src/CMS/components/FileFormDialog.vue @@ -0,0 +1,292 @@ + + + diff --git a/VueApp/src/CMS/components/PermissionSelector.vue b/VueApp/src/CMS/components/PermissionSelector.vue new file mode 100644 index 000000000..90b65837b --- /dev/null +++ b/VueApp/src/CMS/components/PermissionSelector.vue @@ -0,0 +1,61 @@ + + + diff --git a/VueApp/src/CMS/components/PersonSelector.vue b/VueApp/src/CMS/components/PersonSelector.vue new file mode 100644 index 000000000..cb5dc874e --- /dev/null +++ b/VueApp/src/CMS/components/PersonSelector.vue @@ -0,0 +1,81 @@ + + + diff --git a/VueApp/src/CMS/pages/CmsHome.vue b/VueApp/src/CMS/pages/CmsHome.vue index 9dd275f0e..8711149b9 100644 --- a/VueApp/src/CMS/pages/CmsHome.vue +++ b/VueApp/src/CMS/pages/CmsHome.vue @@ -1,5 +1,78 @@ + + diff --git a/VueApp/src/CMS/pages/FileAuditLog.vue b/VueApp/src/CMS/pages/FileAuditLog.vue new file mode 100644 index 000000000..19c645f36 --- /dev/null +++ b/VueApp/src/CMS/pages/FileAuditLog.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/VueApp/src/CMS/pages/Files.vue b/VueApp/src/CMS/pages/Files.vue new file mode 100644 index 000000000..95c4f47cd --- /dev/null +++ b/VueApp/src/CMS/pages/Files.vue @@ -0,0 +1,415 @@ + + + + + diff --git a/VueApp/src/CMS/router/routes.ts b/VueApp/src/CMS/router/routes.ts index 1e37436ac..92358df0a 100644 --- a/VueApp/src/CMS/router/routes.ts +++ b/VueApp/src/CMS/router/routes.ts @@ -19,6 +19,20 @@ const routes = [ meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.ManageContentBlocks"] }, component: () => import("@/CMS/pages/ManageLinkCollections.vue"), }, + { + // Not /CMS/Files: that path is the MVC download handler (CMSController.Files), + // which takes precedence over the SPA shell. + path: "/CMS/ManageFiles", + name: "CmsFiles", + meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.AllFiles"] }, + component: () => import("@/CMS/pages/Files.vue"), + }, + { + path: "/CMS/ManageFiles/Audit", + name: "CmsFileAudit", + meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.AllFiles"] }, + component: () => import("@/CMS/pages/FileAuditLog.vue"), + }, { path: "/:catchAll(.*)*", meta: { layout: ViperLayout }, diff --git a/VueApp/src/CMS/types/index.ts b/VueApp/src/CMS/types/index.ts index 8a2dedfe5..1e12be3b8 100644 --- a/VueApp/src/CMS/types/index.ts +++ b/VueApp/src/CMS/types/index.ts @@ -4,6 +4,49 @@ type ContentBlock = { title: string | null } +type CmsFile = { + fileGuid: string + fileName: string + folder: string | null + friendlyName: string + encrypted: boolean + description: string + allowPublicAccess: boolean + oldUrl: string | null + modifiedOn: string + modifiedBy: string + deletedOn: string | null + permissions: string[] + people: CmsFilePerson[] + url: string + friendlyUrl: string +} + +type CmsFilePerson = { + iamId: string + name: string | null +} + +type CmsPersonOption = { + iamId: string + name: string + loginId: string | null + mailId: string | null +} + +type CmsFileAudit = { + auditId: number + timestamp: string + loginid: string | null + action: string + detail: string | null + fileGuid: string | null + filePath: string | null + iamId: string | null + fileMetaData: string | null + clientData: string | null +} + type LinkCollection = { linkCollectionId: number linkCollection: string @@ -49,4 +92,16 @@ type LinkWithTags = { sortOrder: number } -export type { ContentBlock, LinkCollection, LinkCollectionTagCategory, Link, LinkTag, LinkTagFilter, LinkWithTags } +export type { + ContentBlock, + CmsFile, + CmsFilePerson, + CmsPersonOption, + CmsFileAudit, + LinkCollection, + LinkCollectionTagCategory, + Link, + LinkTag, + LinkTagFilter, + LinkWithTags, +} diff --git a/VueApp/src/composables/ViperFetch.ts b/VueApp/src/composables/ViperFetch.ts index caae23c58..52f1a3e0a 100644 --- a/VueApp/src/composables/ViperFetch.ts +++ b/VueApp/src/composables/ViperFetch.ts @@ -163,8 +163,8 @@ async function fetchWrapper(url: string, options: any = {}) { } }) const resultObj: Result = { - result: result, - errors: errors, + result, + errors, success: errors.length === 0, pagination: null, status: httpStatus, @@ -271,7 +271,21 @@ function useFetch() { return await fetchWrapper(url, options) } - return { get, post, put, del, patch, createUrlSearchParams } + // Multipart variants for file uploads: the browser sets the Content-Type + // (with boundary) itself, so no header is added here. + async function postForm(url: string = "", body: FormData, options: any = {}): Promise { + options.method = "POST" + options.body = body + return await fetchWrapper(url, options) + } + + async function putForm(url: string = "", body: FormData, options: any = {}): Promise { + options.method = "PUT" + options.body = body + return await fetchWrapper(url, options) + } + + return { get, post, put, del, patch, postForm, putForm, createUrlSearchParams } } function downloadBlob(blob: Blob, filename: string): void { diff --git a/web/Areas/CMS/Controllers/CMSOptionsController.cs b/web/Areas/CMS/Controllers/CMSOptionsController.cs new file mode 100644 index 000000000..cc6d9d342 --- /dev/null +++ b/web/Areas/CMS/Controllers/CMSOptionsController.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Constants; +using Viper.Areas.RAPS.Services; +using Viper.Classes; +using Viper.Classes.SQLContext; +using Web.Authorization; + +namespace Viper.Areas.CMS.Controllers +{ + /// + /// Option lists for CMS management forms (permission and person selectors). The RAPS + /// permissions API requires RAPS roles + 2FA, which CMS content managers don't have, + /// so the CMS exposes its own read-only lists gated by the CMS manage permissions. + /// + [Route("/api/cms/options")] + [Permission(Allow = CmsPermissions.AllFiles + "," + CmsPermissions.ManageContentBlocks + "," + CmsPermissions.ManageNavigation)] + public class CMSOptionsController : ApiController + { + private readonly RAPSContext _rapsContext; + private readonly AAUDContext _aaudContext; + + public CMSOptionsController(RAPSContext rapsContext, AAUDContext aaudContext) + { + _rapsContext = rapsContext; + _aaudContext = aaudContext; + } + + /// + /// All VIPER-instance permission names, for tagging files/blocks/nav items with + /// required permissions (matches the legacy CMS permission multi-select). + /// + [HttpGet("permissions")] + public async Task>> GetPermissions(CancellationToken ct = default) + { + return await _rapsContext.TblPermissions + .AsNoTracking() + .Where(RAPSSecurityService.FilterPermissionsToInstance("VIPER")) + .OrderBy(p => p.Permission) + .Select(p => p.Permission) + .ToListAsync(ct); + } + + /// + /// Search current people by name, login id, or mail id for person-level file access. + /// + [HttpGet("people")] + public async Task>> SearchPeople(string search, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(search) || search.Trim().Length < 2) + { + return new List(); + } + search = search.Trim(); + + return await _aaudContext.AaudUsers + .AsNoTracking() + .Where(u => u.Current != 0 && u.IamId != null) + .Where(u => (u.DisplayLastName + ", " + u.DisplayFirstName).Contains(search) + || (u.DisplayFirstName + " " + u.DisplayLastName).Contains(search) + || (u.LoginId != null && u.LoginId.Contains(search)) + || (u.MailId != null && u.MailId.Contains(search))) + .OrderBy(u => u.DisplayLastName) + .ThenBy(u => u.DisplayFirstName) + .Take(25) + .Select(u => new CmsPersonOption + { + IamId = u.IamId!, + Name = u.DisplayLastName + ", " + u.DisplayFirstName, + LoginId = u.LoginId, + MailId = u.MailId + }) + .ToListAsync(ct); + } + } + + public class CmsPersonOption + { + public string IamId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string? LoginId { get; set; } + public string? MailId { get; set; } + } +} From 5d2a10e05bfb2b6c578ccf80338b875e427910c8 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 11 Jun 2026 01:40:43 -0700 Subject: [PATCH 03/40] VPR-59 feat(cms): add content block management with version history - Extend /api/CMS/content: status/system/section/search filters, get-by-id, version history list and retrieval, restore, admin-gated permanent delete, content-only PATCH, file attach/detach, and ModifiedOn-based conflict detection (409 on stale saves) - Fix history semantics to match legacy: store the previous version with its original author/date (was writing the new content, and create wrote a history row with block id 0) - List endpoint projects without the Content column to avoid loading every block body; attached file GUIDs are validated on save - UI: content block list page and editor (QEditor, settings sidebar, permissions, file attachment via search, version history with restore-by-save, conflict dialog) --- .../CMS/components/DeleteRestoreButtons.vue | 37 ++ VueApp/src/CMS/components/PermissionChips.vue | 49 ++ VueApp/src/CMS/pages/CmsHome.vue | 10 + VueApp/src/CMS/pages/ContentBlockEdit.vue | 460 +++++++++++++++++ VueApp/src/CMS/pages/ContentBlocks.vue | 244 +++++++++ VueApp/src/CMS/pages/Files.vue | 74 +-- VueApp/src/CMS/router/routes.ts | 12 + VueApp/src/CMS/types/index.ts | 33 ++ test/CMS/CmsContentBlockServiceTests.cs | 326 ++++++++++++ .../CMS/Controllers/CMSContentController.cs | 252 +++++----- web/Areas/CMS/Models/CMSBlockAddEdit.cs | 8 + web/Areas/CMS/Models/CmsContentBlockMapper.cs | 31 ++ web/Areas/CMS/Models/DTOs/ContentBlockDto.cs | 40 ++ .../CMS/Services/CmsContentBlockService.cs | 466 ++++++++++++++++++ 14 files changed, 1864 insertions(+), 178 deletions(-) create mode 100644 VueApp/src/CMS/components/DeleteRestoreButtons.vue create mode 100644 VueApp/src/CMS/components/PermissionChips.vue create mode 100644 VueApp/src/CMS/pages/ContentBlockEdit.vue create mode 100644 VueApp/src/CMS/pages/ContentBlocks.vue create mode 100644 test/CMS/CmsContentBlockServiceTests.cs create mode 100644 web/Areas/CMS/Models/CmsContentBlockMapper.cs create mode 100644 web/Areas/CMS/Models/DTOs/ContentBlockDto.cs create mode 100644 web/Areas/CMS/Services/CmsContentBlockService.cs 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/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..cc239c1a4 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", 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..b63c072ac --- /dev/null +++ b/VueApp/src/CMS/pages/ContentBlocks.vue @@ -0,0 +1,244 @@ + + + diff --git a/VueApp/src/CMS/pages/Files.vue b/VueApp/src/CMS/pages/Files.vue index 95c4f47cd..c0e0998cb 100644 --- a/VueApp/src/CMS/pages/Files.vue +++ b/VueApp/src/CMS/pages/Files.vue @@ -122,42 +122,10 @@ @@ -206,32 +174,12 @@ > Copy link - - Delete - - - Restore - + @@ -250,6 +198,8 @@ 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 PermissionChips from "@/CMS/components/PermissionChips.vue" import type { CmsFile } from "@/CMS/types/" const apiURL = inject("apiURL") + "cms/files/" diff --git a/VueApp/src/CMS/router/routes.ts b/VueApp/src/CMS/router/routes.ts index 92358df0a..1c091443a 100644 --- a/VueApp/src/CMS/router/routes.ts +++ b/VueApp/src/CMS/router/routes.ts @@ -33,6 +33,18 @@ 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: "/:catchAll(.*)*", meta: { layout: ViperLayout }, diff --git a/VueApp/src/CMS/types/index.ts b/VueApp/src/CMS/types/index.ts index 1e12be3b8..b8748a6a5 100644 --- a/VueApp/src/CMS/types/index.ts +++ b/VueApp/src/CMS/types/index.ts @@ -34,6 +34,36 @@ 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 CmsFileAudit = { auditId: number timestamp: string @@ -94,6 +124,9 @@ type LinkWithTags = { export type { ContentBlock, + CmsContentBlock, + CmsContentBlockFile, + CmsContentHistoryItem, 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/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/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/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(); + } + } +} From 6b8232a75df0c9d4d566690ef17e695b91c05868 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 11 Jun 2026 06:50:10 -0700 Subject: [PATCH 04/40] VPR-59 feat(cms): add left navigation menu management - New /api/cms/left-navs (ManageNavigation): menu CRUD with cascade delete, and a batch item save that adds/updates/deletes/reorders in one call, replacing the legacy DataTables editor flow - Fix nav display filter: items with no permissions are now visible to any logged-in user (legacy behavior); previously they were hidden from rendered menus on Layout/Home/Students pages - Validate left nav item URLs with SafeUrlAttribute - UI: menu list page and item editor with drag/arrow reordering and per-item permission selectors; shared EditButton/ModifiedStamp components extracted from list pages --- VueApp/src/CMS/components/EditButton.vue | 26 + VueApp/src/CMS/components/ModifiedStamp.vue | 22 + VueApp/src/CMS/pages/CmsHome.vue | 7 + VueApp/src/CMS/pages/ContentBlocks.vue | 25 +- VueApp/src/CMS/pages/Files.vue | 21 +- VueApp/src/CMS/pages/LeftNavEdit.vue | 444 ++++++++++++++++++ VueApp/src/CMS/pages/LeftNavMenus.vue | 162 +++++++ VueApp/src/CMS/router/routes.ts | 12 + VueApp/src/CMS/types/index.ts | 22 + test/CMS/CmsLeftNavServiceTests.cs | 181 +++++++ test/CMS/SafeUrlAttributeTests.cs | 70 +++ .../CMS/Controllers/CMSLeftNavController.cs | 91 ++++ web/Areas/CMS/Data/LeftNavMenu.cs | 4 +- web/Areas/CMS/Models/DTOs/LeftNavDtos.cs | 69 +++ web/Areas/CMS/Services/CmsLeftNavService.cs | 265 +++++++++++ web/Areas/CMS/Validation/SafeUrlAttribute.cs | 27 +- 16 files changed, 1408 insertions(+), 40 deletions(-) create mode 100644 VueApp/src/CMS/components/EditButton.vue create mode 100644 VueApp/src/CMS/components/ModifiedStamp.vue create mode 100644 VueApp/src/CMS/pages/LeftNavEdit.vue create mode 100644 VueApp/src/CMS/pages/LeftNavMenus.vue create mode 100644 test/CMS/CmsLeftNavServiceTests.cs create mode 100644 test/CMS/SafeUrlAttributeTests.cs create mode 100644 web/Areas/CMS/Controllers/CMSLeftNavController.cs create mode 100644 web/Areas/CMS/Models/DTOs/LeftNavDtos.cs create mode 100644 web/Areas/CMS/Services/CmsLeftNavService.cs 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/pages/CmsHome.vue b/VueApp/src/CMS/pages/CmsHome.vue index cc239c1a4..58c9aa4aa 100644 --- a/VueApp/src/CMS/pages/CmsHome.vue +++ b/VueApp/src/CMS/pages/CmsHome.vue @@ -82,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/ContentBlocks.vue b/VueApp/src/CMS/pages/ContentBlocks.vue index b63c072ac..d0b0f1d80 100644 --- a/VueApp/src/CMS/pages/ContentBlocks.vue +++ b/VueApp/src/CMS/pages/ContentBlocks.vue @@ -110,26 +110,15 @@ + + + + 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 1c091443a..af1691deb 100644 --- a/VueApp/src/CMS/router/routes.ts +++ b/VueApp/src/CMS/router/routes.ts @@ -45,6 +45,18 @@ const routes = [ 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 b8748a6a5..48cf8746a 100644 --- a/VueApp/src/CMS/types/index.ts +++ b/VueApp/src/CMS/types/index.ts @@ -64,6 +64,27 @@ type CmsContentHistoryItem = { 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 @@ -127,6 +148,7 @@ export type { CmsContentBlock, CmsContentBlockFile, CmsContentHistoryItem, + CmsLeftNavMenu, CmsFile, CmsFilePerson, CmsPersonOption, 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/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/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/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."); } } From 841559cc745c39b562f29748b0d919585af0014c Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 11 Jun 2026 07:34:03 -0700 Subject: [PATCH 05/40] VPR-59 feat(cms): add file import and bulk encryption tools - POST /api/cms/files/import moves files from the legacy VIPER webroot (CMS:LegacyWebrootPath) into the managed store with per-file results; the original path is stored as oldURL so existing links keep working, matching legacy move-and-track semantics; source paths are contained to the webroot and removed only after a fully successful import - POST /api/cms/files/bulk-encrypt encrypts unencrypted files in place with per-file results; a failed key save rolls the file back to plaintext so disk and DB never disagree, and batch failures clear the change tracker so one bad file cannot poison the rest - UI: Import page (paths textarea keeps failures for retry) and Bulk Encrypt page (multi-select over unencrypted files); list pages now surface fetch errors instead of showing silent empty tables --- VueApp/src/CMS/pages/BulkEncrypt.vue | 242 +++++++++++++++ VueApp/src/CMS/pages/CmsHome.vue | 2 + VueApp/src/CMS/pages/FileAuditLog.vue | 5 +- VueApp/src/CMS/pages/Files.vue | 2 + VueApp/src/CMS/pages/ImportFiles.vue | 191 ++++++++++++ VueApp/src/CMS/router/routes.ts | 12 + test/CMS/CmsFileImportServiceTests.cs | 208 +++++++++++++ .../CMS/Controllers/CMSFilesController.cs | 42 ++- .../CMS/Models/DTOs/CmsFileImportDtos.cs | 43 +++ .../CMS/Services/CmsFileImportService.cs | 287 ++++++++++++++++++ 10 files changed, 1031 insertions(+), 3 deletions(-) create mode 100644 VueApp/src/CMS/pages/BulkEncrypt.vue create mode 100644 VueApp/src/CMS/pages/ImportFiles.vue create mode 100644 test/CMS/CmsFileImportServiceTests.cs create mode 100644 web/Areas/CMS/Models/DTOs/CmsFileImportDtos.cs create mode 100644 web/Areas/CMS/Services/CmsFileImportService.cs diff --git a/VueApp/src/CMS/pages/BulkEncrypt.vue b/VueApp/src/CMS/pages/BulkEncrypt.vue new file mode 100644 index 000000000..5224965a2 --- /dev/null +++ b/VueApp/src/CMS/pages/BulkEncrypt.vue @@ -0,0 +1,242 @@ + + + diff --git a/VueApp/src/CMS/pages/CmsHome.vue b/VueApp/src/CMS/pages/CmsHome.vue index 58c9aa4aa..b5bd1ef22 100644 --- a/VueApp/src/CMS/pages/CmsHome.vue +++ b/VueApp/src/CMS/pages/CmsHome.vue @@ -63,6 +63,8 @@ const sections: Section[] = [ actions: [ { label: "Manage Files", to: { name: "CmsFiles" } }, { label: "Audit Log", to: { name: "CmsFileAudit" } }, + { label: "Import", to: { name: "CmsFileImport" } }, + { label: "Bulk Encrypt", to: { name: "CmsBulkEncrypt" } }, ], }, { diff --git a/VueApp/src/CMS/pages/FileAuditLog.vue b/VueApp/src/CMS/pages/FileAuditLog.vue index 19c645f36..e1e6c1897 100644 --- a/VueApp/src/CMS/pages/FileAuditLog.vue +++ b/VueApp/src/CMS/pages/FileAuditLog.vue @@ -121,11 +121,12 @@ diff --git a/VueApp/src/CMS/router/routes.ts b/VueApp/src/CMS/router/routes.ts index af1691deb..b5b7da5dd 100644 --- a/VueApp/src/CMS/router/routes.ts +++ b/VueApp/src/CMS/router/routes.ts @@ -33,6 +33,18 @@ const routes = [ meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.AllFiles"] }, component: () => import("@/CMS/pages/FileAuditLog.vue"), }, + { + path: "/CMS/ManageFiles/Import", + name: "CmsFileImport", + meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.AllFiles"] }, + component: () => import("@/CMS/pages/ImportFiles.vue"), + }, + { + path: "/CMS/ManageFiles/BulkEncrypt", + name: "CmsBulkEncrypt", + meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.AllFiles"] }, + component: () => import("@/CMS/pages/BulkEncrypt.vue"), + }, { path: "/CMS/ManageContentBlocks", name: "CmsContentBlocks", diff --git a/test/CMS/CmsFileImportServiceTests.cs b/test/CMS/CmsFileImportServiceTests.cs new file mode 100644 index 000000000..84cf8a721 --- /dev/null +++ b/test/CMS/CmsFileImportServiceTests.cs @@ -0,0 +1,208 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using NSubstitute; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; +using Viper.Classes.SQLContext; + +namespace Viper.test.CMS; + +/// +/// Tests for CmsFileImportService: importing from the legacy webroot (source containment, +/// move semantics, oldURL tracking, default permission) and bulk encryption. +/// +public sealed class CmsFileImportServiceTests : IDisposable +{ + private readonly string _webroot; + private readonly VIPERContext _context; + private readonly ICmsFileStorageService _storage; + private readonly ICmsFileEncryptionService _encryption; + private readonly ICmsFileAuditService _audit; + private readonly CmsFileImportService _service; + + public CmsFileImportServiceTests() + { + _webroot = Path.Join(Path.GetTempPath(), "ViperCmsImportTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Join(_webroot, "cats", "docs")); + + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + + _storage = Substitute.For(); + _storage.IsValidFolder(Arg.Any()).Returns(true); + _storage.MoveIntoPlace(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + // emulate the real move so source-removal logic can be tested + File.Delete(callInfo.ArgAt(0)); + return Path.Join(@"C:\FakeRoot", callInfo.ArgAt(1), Path.GetFileName(callInfo.ArgAt(2))); + }); + + _encryption = Substitute.For(); + _encryption.GenerateKeyForDb().Returns("encrypted-db-key"); + _audit = Substitute.For(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["CMS:LegacyWebrootPath"] = _webroot }) + .Build(); + + _service = new CmsFileImportService(_context, _storage, _encryption, _audit, + Substitute.For(), configuration); + } + + public void Dispose() + { + _context.Dispose(); + if (Directory.Exists(_webroot)) + { + Directory.Delete(_webroot, recursive: true); + } + } + + private string CreateWebrootFile(string relativePath, string contents = "legacy file") + { + var path = Path.Join(_webroot, relativePath); + File.WriteAllText(path, contents); + return path; + } + + [Fact] + public async Task Import_MovesFileAndRecordsOldUrl() + { + var source = CreateWebrootFile(@"cats\docs\manual.pdf"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "/cats/docs/manual.pdf" }, + Folder = "cats" + }; + + var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.True(result.Success, result.Message); + Assert.Equal("cats-manual.pdf", result.FriendlyName); + Assert.False(File.Exists(source)); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal("/cats/docs/manual.pdf", saved.OldUrl); + Assert.Equal("Automatically imported from Viper", saved.Description); + _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.ImportFile, Arg.Any()); + } + + [Fact] + public async Task Import_DefaultPermission_AddsSvmSecureFolder() + { + CreateWebrootFile(@"cats\docs\manual.pdf"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "cats/docs/manual.pdf" }, + Folder = @"cats\docs", + UseDefaultPermission = true, + Permissions = new List { "SVMSecure.Extra" } + }; + + await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken); + + var saved = await _context.Files.Include(f => f.FileToPermissions).SingleAsync(TestContext.Current.CancellationToken); + var permissions = saved.FileToPermissions.Select(p => p.Permission).ToList(); + Assert.Equal(2, permissions.Count); + Assert.Contains("SVMSecure.Extra", permissions); + Assert.Contains("SVMSecure.cats", permissions); + } + + [Fact] + public async Task Import_TraversalOutsideWebroot_Fails() + { + var request = new CmsFileImportRequest + { + FilePaths = new List { @"..\..\Windows\system.ini" }, + Folder = "cats" + }; + + var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.Contains("outside", result.Message); + Assert.Empty(await _context.Files.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Import_MissingAndDisallowedFiles_ReportPerFileErrors() + { + CreateWebrootFile(@"cats\evil.bat", "nope"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "cats/missing.pdf", "cats/evil.bat" }, + Folder = "cats" + }; + + var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(2, results.Count); + Assert.All(results, r => Assert.False(r.Success)); + Assert.Contains("not found", results[0].Message); + Assert.Contains("not allowed", results[1].Message); + } + + [Fact] + public async Task Import_WithEncrypt_EncryptsBeforeMove() + { + CreateWebrootFile(@"cats\docs\secret.pdf"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "cats/docs/secret.pdf" }, + Folder = "cats", + Encrypt = true + }; + + var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken); + + Assert.True(results[0].Success); + _encryption.Received(1).EncryptFileInPlace(Arg.Any(), "encrypted-db-key"); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.True(saved.Encrypted); + Assert.Equal("encrypted-db-key", saved.Key); + } + + [Fact] + public async Task BulkEncrypt_EncryptsUnencryptedAndSkipsOthers() + { + var plain = new Viper.Models.VIPER.File + { + FileGuid = Guid.NewGuid(), + FilePath = @"C:\FakeRoot\cats\plain.pdf", + Folder = "cats", + FriendlyName = "cats-plain.pdf", + Description = "", + ModifiedBy = "test", + ModifiedOn = DateTime.Now + }; + var already = new Viper.Models.VIPER.File + { + FileGuid = Guid.NewGuid(), + FilePath = @"C:\FakeRoot\cats\already.pdf", + Folder = "cats", + FriendlyName = "cats-already.pdf", + Description = "", + Encrypted = true, + Key = "existing-key", + ModifiedBy = "test", + ModifiedOn = DateTime.Now + }; + _context.Files.AddRange(plain, already); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + _storage.ManagedFileExists(plain.FilePath).Returns(true); + + var results = await _service.BulkEncryptAsync( + new List { plain.FileGuid, already.FileGuid, Guid.NewGuid() }, TestContext.Current.CancellationToken); + + Assert.Equal(3, results.Count); + Assert.True(results[0].Success); + Assert.False(results[1].Success); + Assert.Contains("Already encrypted", results[1].Message); + Assert.False(results[2].Success); + _encryption.Received(1).EncryptFileInPlace(plain.FilePath, "encrypted-db-key"); + var saved = await _context.Files.SingleAsync(f => f.FileGuid == plain.FileGuid, TestContext.Current.CancellationToken); + Assert.True(saved.Encrypted); + } +} diff --git a/web/Areas/CMS/Controllers/CMSFilesController.cs b/web/Areas/CMS/Controllers/CMSFilesController.cs index a521b25a6..8a3cc70d5 100644 --- a/web/Areas/CMS/Controllers/CMSFilesController.cs +++ b/web/Areas/CMS/Controllers/CMSFilesController.cs @@ -24,17 +24,19 @@ public class CMSFilesController : ApiController private readonly ICmsFileService _fileService; private readonly ICmsFileStorageService _storage; private readonly ICmsFileAuditService _auditService; + private readonly ICmsFileImportService _importService; private readonly RAPSContext _rapsContext; private readonly ILogger _logger; private readonly IUserHelper _userHelper; public CMSFilesController(ICmsFileService fileService, ICmsFileStorageService storage, - ICmsFileAuditService auditService, RAPSContext rapsContext, ILogger logger, - IUserHelper userHelper) + ICmsFileAuditService auditService, ICmsFileImportService importService, RAPSContext rapsContext, + ILogger logger, IUserHelper userHelper) { _fileService = fileService; _storage = storage; _auditService = auditService; + _importService = importService; _rapsContext = rapsContext; _logger = logger; _userHelper = userHelper; @@ -193,6 +195,42 @@ public async Task RestoreFile(Guid fileGuid, CancellationToken ct return await _fileService.RestoreFileAsync(fileGuid, ct) ? Ok() : NotFound(); } + // POST /api/cms/files/import — move files from the legacy VIPER webroot into the store + [HttpPost("import")] + public async Task>> ImportFiles(CmsFileImportRequest request, + CancellationToken ct = default) + { + if (request.FilePaths.Count == 0) + { + return BadRequest("At least one file path is required."); + } + + try + { + return await _importService.ImportFilesAsync(request, ct); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + // POST /api/cms/files/bulk-encrypt + [HttpPost("bulk-encrypt")] + public async Task>> BulkEncrypt(List fileGuids, + CancellationToken ct = default) + { + if (fileGuids.Count == 0) + { + return BadRequest("At least one file is required."); + } + return await _importService.BulkEncryptAsync(fileGuids, ct); + } + private void LogFileConflict(string operation, string? folder, string? fileName, Exception ex) { _logger.LogWarning(ex, "CMS file {Operation} conflict folder={Folder} fileName={FileName}", diff --git a/web/Areas/CMS/Models/DTOs/CmsFileImportDtos.cs b/web/Areas/CMS/Models/DTOs/CmsFileImportDtos.cs new file mode 100644 index 000000000..09c9fe505 --- /dev/null +++ b/web/Areas/CMS/Models/DTOs/CmsFileImportDtos.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; + +namespace Viper.Areas.CMS.Models.DTOs +{ + /// + /// Import existing files from the legacy VIPER webroot into the managed file store. + /// Paths are relative to the configured legacy webroot. + /// + public class CmsFileImportRequest + { + [Required] + public List FilePaths { get; set; } = new(); + + [Required] + public string Folder { get; set; } = string.Empty; + + public List Permissions { get; set; } = new(); + + /// Add the folder's default permission (SVMSecure.{folder}) to each file. + public bool? UseDefaultPermission { get; set; } + + public bool? AllowPublicAccess { get; set; } + + public bool? Encrypt { get; set; } + } + + public class CmsFileImportResult + { + public string FilePath { get; set; } = string.Empty; + public bool Success { get; set; } + public string? Message { get; set; } + public Guid? FileGuid { get; set; } + public string? FriendlyName { get; set; } + } + + public class CmsBulkEncryptResult + { + public Guid FileGuid { get; set; } + public string? FriendlyName { get; set; } + public bool Success { get; set; } + public string? Message { get; set; } + } +} diff --git a/web/Areas/CMS/Services/CmsFileImportService.cs b/web/Areas/CMS/Services/CmsFileImportService.cs new file mode 100644 index 000000000..ff7b5abd6 --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileImportService.cs @@ -0,0 +1,287 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Classes.SQLContext; +using File = Viper.Models.VIPER.File; + +namespace Viper.Areas.CMS.Services +{ + public interface ICmsFileImportService + { + Task> ImportFilesAsync(CmsFileImportRequest request, CancellationToken ct = default); + + Task> BulkEncryptAsync(List fileGuids, CancellationToken ct = default); + } + + /// + /// Bulk file tools mirroring the legacy CMS: import loose files from the legacy VIPER + /// webroot into the managed store (recording the original path as oldURL so legacy links + /// keep working via the oldURL lookup), and bulk-encrypt existing unencrypted files. + /// + public class CmsFileImportService : ICmsFileImportService + { + private readonly VIPERContext _context; + private readonly ICmsFileStorageService _storage; + private readonly ICmsFileEncryptionService _encryption; + private readonly ICmsFileAuditService _audit; + private readonly IUserHelper _userHelper; + private readonly string? _legacyWebroot; + + public CmsFileImportService(VIPERContext context, ICmsFileStorageService storage, + ICmsFileEncryptionService encryption, ICmsFileAuditService audit, IUserHelper userHelper, + IConfiguration configuration) + { + _context = context; + _storage = storage; + _encryption = encryption; + _audit = audit; + _userHelper = userHelper; + _legacyWebroot = configuration["CMS:LegacyWebrootPath"] + ?? (HttpHelper.Environment?.EnvironmentName == "Development" ? @"C:\Sites\https\VIPER" : null); + } + + public async Task> ImportFilesAsync(CmsFileImportRequest request, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(_legacyWebroot)) + { + throw new InvalidOperationException("CMS:LegacyWebrootPath is not configured for this environment."); + } + if (!_storage.IsValidFolder(request.Folder)) + { + throw new ArgumentException("Invalid folder."); + } + + var permissions = request.Permissions + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim()) + .ToList(); + if (request.UseDefaultPermission == true) + { + string topFolder = request.Folder.Split(['\\', '/'], StringSplitOptions.None)[0]; + permissions.Add("SVMSecure." + topFolder); + } + permissions = permissions.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + var results = new List(); + foreach (var rawPath in request.FilePaths.Where(p => !string.IsNullOrWhiteSpace(p))) + { + results.Add(await ImportSingleFileAsync(rawPath.Trim(), request, permissions, ct)); + } + return results; + } + + private async Task ImportSingleFileAsync(string rawPath, CmsFileImportRequest request, + List permissions, CancellationToken ct) + { + var result = new CmsFileImportResult { FilePath = rawPath }; + try + { + string relative = rawPath.TrimStart('/', '\\').Replace('/', '\\'); + string sourcePath = Path.GetFullPath(Path.Join(_legacyWebroot, relative)); + string webroot = Path.GetFullPath(_legacyWebroot! + Path.DirectorySeparatorChar); + if (!sourcePath.StartsWith(webroot, StringComparison.OrdinalIgnoreCase)) + { + result.Message = "Path is outside the legacy webroot."; + return result; + } + if (!System.IO.File.Exists(sourcePath)) + { + result.Message = "File not found."; + return result; + } + string fileName = Path.GetFileName(sourcePath); + if (!CmsFileTypes.IsAllowedFileName(fileName)) + { + result.Message = "File type is not allowed."; + return result; + } + + bool encrypt = request.Encrypt ?? false; + string? dbKey = encrypt ? _encryption.GenerateKeyForDb() : null; + + // Copy to temp, transform, move into the store; the source is removed only + // after the import fully succeeds (legacy used move semantics). + string tempPath = Path.Join(Path.GetTempPath(), "Viper-CMS-Uploads", Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(tempPath)!); + System.IO.File.Copy(sourcePath, tempPath); + + string finalPath; + try + { + if (dbKey != null) + { + _encryption.EncryptFileInPlace(tempPath, dbKey); + } + finalPath = _storage.MoveIntoPlace(tempPath, request.Folder, fileName, makeUnique: true); + } + finally + { + if (System.IO.File.Exists(tempPath)) + { + System.IO.File.Delete(tempPath); + } + } + + string friendlyName = request.Folder.Replace('\\', '-').Replace('/', '-') + "-" + Path.GetFileName(finalPath); + if (await _context.Files.AnyAsync(f => f.FriendlyName == friendlyName, ct)) + { + _storage.DeleteManagedFile(finalPath); + result.Message = $"A file with the name {friendlyName} already exists."; + return result; + } + + var entity = new File + { + FileGuid = Guid.NewGuid(), + FilePath = finalPath, + Folder = request.Folder, + FriendlyName = friendlyName, + Encrypted = encrypt, + Key = dbKey, + Description = "Automatically imported from Viper", + AllowPublicAccess = request.AllowPublicAccess ?? false, + OldUrl = "/" + relative.Replace('\\', '/'), + ModifiedOn = DateTime.Now, + ModifiedBy = CurrentLoginId() + }; + foreach (var permission in permissions) + { + entity.FileToPermissions.Add(new Viper.Models.VIPER.FileToPermission { FileGuid = entity.FileGuid, Permission = permission }); + } + + _context.Files.Add(entity); + _audit.Audit(entity, CmsFileAuditActions.ImportFile, $"Imported from {entity.OldUrl}"); + try + { + await _context.SaveChangesAsync(ct); + } + catch (Exception ex) when (ex is DbUpdateException or SqlException) + { + // Drop the failed entities so they can't poison later saves in this batch, + // and remove the stored copy; the untouched source can be re-imported. + _context.ChangeTracker.Clear(); + _storage.DeleteManagedFile(finalPath); + result.Message = ex.InnerException?.Message ?? ex.Message; + return result; + } + + System.IO.File.Delete(sourcePath); + + result.Success = true; + result.FileGuid = entity.FileGuid; + result.FriendlyName = friendlyName; + return result; + } + catch (IOException ex) + { + result.Message = ex.Message; + return result; + } + catch (UnauthorizedAccessException ex) + { + result.Message = ex.Message; + return result; + } + catch (InvalidOperationException ex) + { + result.Message = ex.Message; + return result; + } + catch (ArgumentException ex) + { + result.Message = ex.Message; + return result; + } + } + + public async Task> BulkEncryptAsync(List fileGuids, CancellationToken ct = default) + { + var results = new List(); + foreach (var fileGuid in fileGuids.Distinct()) + { + results.Add(await EncryptSingleFileAsync(fileGuid, ct)); + } + return results; + } + + private async Task EncryptSingleFileAsync(Guid fileGuid, CancellationToken ct) + { + var result = new CmsBulkEncryptResult { FileGuid = fileGuid }; + var file = await _context.Files.FirstOrDefaultAsync(f => f.FileGuid == fileGuid, ct); + if (file == null) + { + result.Message = "File not found."; + return result; + } + result.FriendlyName = file.FriendlyName; + if (file.Encrypted) + { + result.Message = "Already encrypted."; + return result; + } + if (!_storage.ManagedFileExists(file.FilePath)) + { + result.Message = "File is missing on disk."; + return result; + } + + string? dbKey = null; + try + { + dbKey = _encryption.GenerateKeyForDb(); + _encryption.EncryptFileInPlace(file.FilePath, dbKey); + file.Key = dbKey; + file.Encrypted = true; + file.ModifiedOn = DateTime.Now; + file.ModifiedBy = CurrentLoginId(); + _audit.Audit(file, CmsFileAuditActions.EditFile, "Encrypted file (bulk)"); + // The file is already encrypted on disk; cancelling this save would lose the + // key, so it runs to completion even if the request is aborted. + await _context.SaveChangesAsync(CancellationToken.None); + result.Success = true; + } + catch (Exception ex) when (ex is DbUpdateException or SqlException) + { + RollBackUnsavedEncryption(file, dbKey!, ex, result); + } + catch (IOException ex) + { + result.Message = ex.Message; + } + catch (UnauthorizedAccessException ex) + { + result.Message = ex.Message; + } + catch (InvalidOperationException ex) + { + result.Message = ex.Message; + } + return result; + } + + /// + /// A failed save means the key was never persisted, so decrypt the file back to plain + /// text and drop the pending changes so they can't poison later saves in the batch. + /// + private void RollBackUnsavedEncryption(File file, string dbKey, Exception saveEx, CmsBulkEncryptResult result) + { + _context.ChangeTracker.Clear(); + try + { + _encryption.DecryptFileInPlace(file.FilePath, dbKey); + result.Message = saveEx.InnerException?.Message ?? saveEx.Message; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + result.Message = $"{saveEx.Message} The file could not be restored ({ex.Message}); " + + "it is encrypted on disk but the key was not saved. Restore it from backup."; + } + } + + private string CurrentLoginId() + { + return _userHelper.GetCurrentUser()?.LoginId ?? "unknown"; + } + } +} From 689a959aef3526da80cfff15621cce56754404f6 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 11 Jun 2026 08:00:43 -0700 Subject: [PATCH 06/40] VPR-59 feat(cms): add user photo endpoints - /api/cms/photos serves user photos to any authenticated user by mailId, loginId, or iamId (legacy userPhoto.cfc parity), resolving ids through AAUD - altPhoto=true serves the alternate ProfilePhotos/{iamId}.jpg when present; invalid or traversal-shaped ids fall back to the default photo without touching disk outside the photo roots - ID-card photo lookup, caching, and the nopic fallback reuse the Students area PhotoService rather than duplicating the pipeline --- test/CMS/CmsUserPhotoServiceTests.cs | 162 ++++++++++++++++++ .../CMS/Controllers/CMSUserPhotoController.cs | 57 ++++++ web/Areas/CMS/Services/CmsUserPhotoService.cs | 129 ++++++++++++++ 3 files changed, 348 insertions(+) create mode 100644 test/CMS/CmsUserPhotoServiceTests.cs create mode 100644 web/Areas/CMS/Controllers/CMSUserPhotoController.cs create mode 100644 web/Areas/CMS/Services/CmsUserPhotoService.cs diff --git a/test/CMS/CmsUserPhotoServiceTests.cs b/test/CMS/CmsUserPhotoServiceTests.cs new file mode 100644 index 000000000..e3efb7fa0 --- /dev/null +++ b/test/CMS/CmsUserPhotoServiceTests.cs @@ -0,0 +1,162 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Viper.Areas.CMS.Services; +using Viper.Areas.Students.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.CMS; + +/// +/// Tests for CmsUserPhotoService: AAUD id resolution, alternate ProfilePhotos lookup with +/// traversal protection, and delegation to the Students photo pipeline. +/// +public sealed class CmsUserPhotoServiceTests : IDisposable +{ + private readonly string _profilePhotoRoot; + private readonly AAUDContext _aaudContext; + private readonly IPhotoService _photoService; + private readonly CmsUserPhotoService _service; + + private static readonly byte[] IdCardPhotoBytes = { 1, 1, 1 }; + private static readonly byte[] AltPhotoBytes = { 2, 2, 2 }; + + public CmsUserPhotoServiceTests() + { + _profilePhotoRoot = Path.Join(Path.GetTempPath(), "ViperCmsPhotoTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_profilePhotoRoot); + + _aaudContext = new AAUDContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("AAUD_" + Guid.NewGuid()).Options); + + _photoService = Substitute.For(); + _photoService.GetStudentPhotoAsync(Arg.Any()).Returns(Task.FromResult(IdCardPhotoBytes)); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["CMS:ProfilePhotoPath"] = _profilePhotoRoot }) + .Build(); + + _service = new CmsUserPhotoService(_aaudContext, _photoService, configuration, + Substitute.For>()); + } + + public void Dispose() + { + _aaudContext.Dispose(); + if (Directory.Exists(_profilePhotoRoot)) + { + Directory.Delete(_profilePhotoRoot, recursive: true); + } + } + + private void SeedUser(string loginId = "jdoe", string mailId = "jdoe", string iamId = "1000999") + { + _aaudContext.AaudUsers.Add(new AaudUser + { + AaudUserId = 1, + ClientId = "test-client", + LoginId = loginId, + MailId = mailId, + IamId = iamId, + Current = 1, + LastName = "Doe", + FirstName = "Jane", + DisplayLastName = "Doe", + DisplayFirstName = "Jane", + DisplayFullName = "Doe, Jane", + MothraId = "m1", + SpridenId = null, + Pidm = null, + EmployeeId = null, + VmacsId = null, + VmcasId = null, + UnexId = null + }); + _aaudContext.SaveChanges(); + } + + [Fact] + public async Task GetUserPhoto_ByMailId_DelegatesToStudentPipeline() + { + var photo = await _service.GetUserPhotoAsync("jdoe", null, null, preferAltPhoto: false, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo); + await _photoService.Received(1).GetStudentPhotoAsync("jdoe"); + } + + [Fact] + public async Task GetUserPhoto_ByLoginId_ResolvesMailIdThroughAaud() + { + SeedUser(loginId: "jdoe", mailId: "janedoe"); + + await _service.GetUserPhotoAsync(null, "jdoe", null, preferAltPhoto: false, + TestContext.Current.CancellationToken); + + await _photoService.Received(1).GetStudentPhotoAsync("janedoe"); + } + + [Fact] + public async Task GetUserPhoto_UnknownLoginId_FallsBackToDefaultPipeline() + { + var photo = await _service.GetUserPhotoAsync(null, "nosuchuser", null, preferAltPhoto: false, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo); + await _photoService.Received(1).GetStudentPhotoAsync(string.Empty); + } + + [Fact] + public async Task GetUserPhoto_PreferAltPhoto_ServesProfilePhoto() + { + SeedUser(iamId: "1000999"); + await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes, + TestContext.Current.CancellationToken); + + var photo = await _service.GetUserPhotoAsync(null, "jdoe", null, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(AltPhotoBytes, photo); + await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any()); + } + + [Fact] + public async Task GetUserPhoto_ByMailIdPreferAltPhoto_ResolvesIamIdAndServesProfilePhoto() + { + SeedUser(mailId: "jdoe", iamId: "1000999"); + await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes, + TestContext.Current.CancellationToken); + + var photo = await _service.GetUserPhotoAsync("jdoe", null, null, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(AltPhotoBytes, photo); + await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any()); + } + + [Fact] + public async Task GetUserPhoto_PreferAltPhoto_MissingAltFallsBackToIdCard() + { + SeedUser(mailId: "janedoe", iamId: "1000999"); + + var photo = await _service.GetUserPhotoAsync(null, "jdoe", null, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo); + await _photoService.Received(1).GetStudentPhotoAsync("janedoe"); + } + + [Theory] + [InlineData("../1000999")] + [InlineData("..\\secrets")] + [InlineData("a/b")] + public async Task GetUserPhoto_TraversalShapedIamId_IgnoresAltPhoto(string iamId) + { + var photo = await _service.GetUserPhotoAsync(null, null, iamId, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo); + } +} diff --git a/web/Areas/CMS/Controllers/CMSUserPhotoController.cs b/web/Areas/CMS/Controllers/CMSUserPhotoController.cs new file mode 100644 index 000000000..c299e1d1e --- /dev/null +++ b/web/Areas/CMS/Controllers/CMSUserPhotoController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.CMS.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.CMS.Controllers +{ + /// + /// User photos for authenticated VIPER pages (legacy userPhoto.cfc). Photos are visible + /// to any logged-in user, matching legacy behavior; gallery-style browsing with its own + /// permissions lives in the Students area. + /// + [Route("/api/cms/photos")] + [Permission(Allow = "SVMSecure")] + public class CMSUserPhotoController : ApiController + { + private const int CacheSeconds = 3600; + + private readonly ICmsUserPhotoService _photoService; + + public CMSUserPhotoController(ICmsUserPhotoService photoService) + { + _photoService = photoService; + } + + // GET /api/cms/photos/by-mail/{mailId}?altPhoto=true|false + [HttpGet("by-mail/{mailId}")] + public async Task GetByMailId(string mailId, bool altPhoto = false, CancellationToken ct = default) + { + return await ServePhoto(mailId: mailId, loginId: null, iamId: null, altPhoto, ct); + } + + // GET /api/cms/photos/by-login/{loginId}?altPhoto=true|false + [HttpGet("by-login/{loginId}")] + public async Task GetByLoginId(string loginId, bool altPhoto = false, CancellationToken ct = default) + { + return await ServePhoto(mailId: null, loginId: loginId, iamId: null, altPhoto, ct); + } + + // GET /api/cms/photos/by-iam/{iamId}?altPhoto=true|false + [HttpGet("by-iam/{iamId}")] + public async Task GetByIamId(string iamId, bool altPhoto = false, CancellationToken ct = default) + { + return await ServePhoto(mailId: null, loginId: null, iamId: iamId, altPhoto, ct); + } + + private async Task ServePhoto(string? mailId, string? loginId, string? iamId, + bool altPhoto, CancellationToken ct) + { + var photo = await _photoService.GetUserPhotoAsync(mailId, loginId, iamId, altPhoto, ct); + // Photos change rarely; let browsers cache for an hour (legacy used short-lived + // cache headers with long stale-while-revalidate). + Response.Headers.CacheControl = $"private, max-age={CacheSeconds}"; + return File(photo, "image/jpeg"); + } + } +} diff --git a/web/Areas/CMS/Services/CmsUserPhotoService.cs b/web/Areas/CMS/Services/CmsUserPhotoService.cs new file mode 100644 index 000000000..c568966cb --- /dev/null +++ b/web/Areas/CMS/Services/CmsUserPhotoService.cs @@ -0,0 +1,129 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Students.Services; +using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; + +namespace Viper.Areas.CMS.Services +{ + public interface ICmsUserPhotoService + { + /// + /// Get a user photo by any supported id. Resolution order matches legacy userPhoto.cfc: + /// alternate profile photo (by IamId, only when requested via preferAltPhoto), + /// then id-card photo (by MailId), then the default "no picture" image. + /// + Task GetUserPhotoAsync(string? mailId, string? loginId, string? iamId, bool preferAltPhoto, CancellationToken ct = default); + } + + /// + /// User photos for the CMS area (legacy userPhoto.cfc). Id-card photo lookup, caching, and + /// the default-photo fallback are delegated to the Students area IPhotoService so there is + /// a single photo pipeline; this service adds AAUD id resolution and the alternate + /// ProfilePhotos store. + /// + public partial class CmsUserPhotoService : ICmsUserPhotoService + { + private readonly AAUDContext _aaudContext; + private readonly IPhotoService _photoService; + private readonly ILogger _logger; + private readonly string _profilePhotoPath; + + [GeneratedRegex("^[a-zA-Z0-9.-]+$")] + private static partial Regex SafeIdRegex(); + + public CmsUserPhotoService(AAUDContext aaudContext, IPhotoService photoService, + IConfiguration configuration, ILogger logger) + { + _aaudContext = aaudContext; + _photoService = photoService; + _logger = logger; + // Alternate profile photos live beside IDCardPhotos under the file storage root. + _profilePhotoPath = configuration["CMS:ProfilePhotoPath"] + ?? Path.Join(Data.CMS.GetRootFileFolder(), "ProfilePhotos"); + } + + public async Task GetUserPhotoAsync(string? mailId, string? loginId, string? iamId, + bool preferAltPhoto, CancellationToken ct = default) + { + // Resolve whichever id was provided to the person's mailId (+ iamId when needed). + (mailId, iamId) = await ResolveIdsAsync(mailId, loginId, iamId, needIamId: preferAltPhoto, ct); + + if (preferAltPhoto && iamId != null) + { + var altPhoto = await ReadAltPhotoAsync(iamId, ct); + if (altPhoto != null) + { + return altPhoto; + } + } + + return await _photoService.GetStudentPhotoAsync(mailId ?? string.Empty); + } + + private async Task<(string? MailId, string? IamId)> ResolveIdsAsync(string? mailId, string? loginId, + string? iamId, bool needIamId, CancellationToken ct) + { + // Skip the lookup when every id the caller needs is already known. A mailId alone + // serves the id-card photo with no DB query (the legacy hot path for tags). + if (!string.IsNullOrEmpty(mailId) && (!string.IsNullOrEmpty(iamId) || !needIamId)) + { + return (mailId, iamId); + } + + var query = _aaudContext.AaudUsers.AsNoTracking().Where(u => u.Current != 0); + if (!string.IsNullOrEmpty(mailId)) + { + query = query.Where(u => u.MailId == mailId); + } + else if (!string.IsNullOrEmpty(loginId)) + { + query = query.Where(u => u.LoginId == loginId); + } + else if (!string.IsNullOrEmpty(iamId)) + { + query = query.Where(u => u.IamId == iamId); + } + else + { + return (null, null); + } + + var user = await query + .Select(u => new { u.MailId, u.IamId }) + .FirstOrDefaultAsync(ct); + return user == null ? (mailId, iamId) : (user.MailId ?? mailId, user.IamId ?? iamId); + } + + private async Task ReadAltPhotoAsync(string iamId, CancellationToken ct) + { + if (!SafeIdRegex().IsMatch(iamId)) + { + _logger.LogWarning("Rejected alt photo request with invalid iamId {IamId}", LogSanitizer.SanitizeId(iamId)); + return null; + } + + var photoPath = Path.GetFullPath(Path.Join(_profilePhotoPath, Path.GetFileName(iamId) + ".jpg")); + var root = Path.GetFullPath(_profilePhotoPath + Path.DirectorySeparatorChar); + if (!photoPath.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !System.IO.File.Exists(photoPath)) + { + return null; + } + + try + { + return await System.IO.File.ReadAllBytesAsync(photoPath, ct); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error reading alt photo for {IamId}", LogSanitizer.SanitizeId(iamId)); + return null; + } + catch (UnauthorizedAccessException ex) + { + _logger.LogError(ex, "Access denied reading alt photo for {IamId}", LogSanitizer.SanitizeId(iamId)); + return null; + } + } + } +} From 8c31439e28a13dc0d4158c19cd9237fe87a44c38 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 11 Jun 2026 17:36:27 -0700 Subject: [PATCH 07/40] VPR-59 feat(cms): rate limit file and ZIP downloads - ZIP requests get a strict per-user token bucket (archives are assembled in memory); single files get a generous sliding window - Buckets key on CAS login, falling back to the proxy-resolved client IP for anonymous requests; rejections return 429 with Retry-After - Limits are configurable under CMS:DownloadRateLimit; invalid values fall back to defaults instead of failing downloads --- test/CMS/CmsDownloadRateLimitingTests.cs | 88 +++++++++++++ web/Areas/CMS/Controllers/CMSController.cs | 3 + .../CMS/Services/CmsDownloadRateLimiting.cs | 118 ++++++++++++++++++ web/Program.cs | 7 ++ 4 files changed, 216 insertions(+) create mode 100644 test/CMS/CmsDownloadRateLimitingTests.cs create mode 100644 web/Areas/CMS/Services/CmsDownloadRateLimiting.cs diff --git a/test/CMS/CmsDownloadRateLimitingTests.cs b/test/CMS/CmsDownloadRateLimitingTests.cs new file mode 100644 index 000000000..4d47c538f --- /dev/null +++ b/test/CMS/CmsDownloadRateLimitingTests.cs @@ -0,0 +1,88 @@ +using System.Net; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Viper.Areas.CMS.Services; + +namespace Viper.test.CMS; + +/// +/// Tests for the CMS download rate-limit partitioning: ZIP and single-file requests get +/// separate buckets, keyed by login when authenticated and client IP otherwise. +/// +public sealed class CmsDownloadRateLimitingTests +{ + private static DefaultHttpContext MakeContext(string? queryString = null, string? login = null, string? ip = null) + { + var context = new DefaultHttpContext(); + if (queryString != null) + { + context.Request.QueryString = new QueryString(queryString); + } + if (login != null) + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.Name, login) }, authenticationType: "test")); + } + if (ip != null) + { + context.Connection.RemoteIpAddress = IPAddress.Parse(ip); + } + return context; + } + + [Theory] + [InlineData("?ids=abc", true)] + [InlineData("?ids=abc,def&fileName=x.zip", true)] + [InlineData("?ids=", false)] + [InlineData("?id=abc", false)] + [InlineData("?fn=somefile.pdf", false)] + [InlineData(null, false)] + public void IsZipRequest_DetectsIdsParameter(string? query, bool expected) + { + var context = MakeContext(query); + + Assert.Equal(expected, CmsDownloadRateLimiting.IsZipRequest(context)); + } + + [Fact] + public void PartitionKey_ZipAndFileRequests_GetSeparateBuckets() + { + var zipKey = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?ids=a,b", login: "rexl")); + var fileKey = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", login: "rexl")); + + Assert.NotEqual(zipKey, fileKey); + Assert.StartsWith("zip|", zipKey); + Assert.StartsWith("file|", fileKey); + } + + [Fact] + public void PartitionKey_AuthenticatedUser_KeysOnLoginNotIp() + { + var sameUserIp1 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", login: "rexl", ip: "10.0.0.1")); + var sameUserIp2 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", login: "rexl", ip: "10.0.0.2")); + var otherUserSameIp = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", login: "bob", ip: "10.0.0.1")); + + Assert.Equal(sameUserIp1, sameUserIp2); + Assert.NotEqual(sameUserIp1, otherUserSameIp); + } + + [Fact] + public void PartitionKey_Anonymous_KeysOnClientIp() + { + var ip1 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", ip: "10.0.0.1")); + var ip2 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf", ip: "10.0.0.2")); + + Assert.NotEqual(ip1, ip2); + Assert.Contains("10.0.0.1", ip1); + } + + [Fact] + public void PartitionKey_AnonymousWithoutIp_UsesStableFallback() + { + var key1 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=x.pdf")); + var key2 = CmsDownloadRateLimiting.GetPartitionKey(MakeContext("?fn=y.pdf")); + + Assert.Equal("file|unknown", key1); + Assert.Equal(key1, key2); + } +} diff --git a/web/Areas/CMS/Controllers/CMSController.cs b/web/Areas/CMS/Controllers/CMSController.cs index e7703e074..c6232b931 100644 --- a/web/Areas/CMS/Controllers/CMSController.cs +++ b/web/Areas/CMS/Controllers/CMSController.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Viper.Areas.CMS.Services; using Viper.Classes.SQLContext; using Viper.Services; @@ -21,6 +23,7 @@ public CMSController(RAPSContext rapsContext, VIPERContext viperContext, IHtmlSa } [HttpGet] + [EnableRateLimiting(CmsDownloadRateLimiting.PolicyName)] public IActionResult Files(string id = "", string fn = "", string oldURL = "", string ids = "", string fileName = "") { Data.CMS cms = new(_viperContext, _rapsContext, _sanitizerService, _cmsLogger); diff --git a/web/Areas/CMS/Services/CmsDownloadRateLimiting.cs b/web/Areas/CMS/Services/CmsDownloadRateLimiting.cs new file mode 100644 index 000000000..c3a8cf90d --- /dev/null +++ b/web/Areas/CMS/Services/CmsDownloadRateLimiting.cs @@ -0,0 +1,118 @@ +using System.Globalization; +using System.Threading.RateLimiting; +using Viper.Classes.Utilities; + +namespace Viper.Areas.CMS.Services +{ + /// + /// Rate limiting for the CMS download endpoint (/CMS/Files). ZIP requests assemble + /// multi-file archives in memory, so they get a strict token bucket; single-file + /// downloads get a generous sliding window that allows image-heavy pages but stops + /// sustained scraping. Buckets are per login id when authenticated, per client IP + /// otherwise (forwarded headers are already resolved for the Cloudflare/F5 chain, + /// so RemoteIpAddress is the real client on TEST/PROD). + /// + public static class CmsDownloadRateLimiting + { + public const string PolicyName = "CmsDownloads"; + + private const string ZipPrefix = "zip|"; + private const string FilePrefix = "file|"; + + // Defaults; override under CMS:DownloadRateLimit in appsettings. + private const int DefaultFilePermitsPerMinute = 100; + private const int DefaultZipTokenLimit = 5; + private const int DefaultZipTokensPerMinute = 1; + + public static IServiceCollection AddCmsDownloadRateLimiting(this IServiceCollection services, IConfiguration configuration) + { + int filePermits = configuration.GetValue("CMS:DownloadRateLimit:FilePermitsPerMinute", DefaultFilePermitsPerMinute); + int zipTokenLimit = configuration.GetValue("CMS:DownloadRateLimit:ZipTokenLimit", DefaultZipTokenLimit); + int zipTokensPerMinute = configuration.GetValue("CMS:DownloadRateLimit:ZipTokensPerMinute", DefaultZipTokensPerMinute); + + // Misconfigured (zero/negative) values would make the limiter option + // constructors throw on the first request, so fall back to defaults. + if (filePermits <= 0) filePermits = DefaultFilePermitsPerMinute; + if (zipTokenLimit <= 0) zipTokenLimit = DefaultZipTokenLimit; + if (zipTokensPerMinute <= 0) zipTokensPerMinute = DefaultZipTokensPerMinute; + + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (context, ct) => + { + var httpContext = context.HttpContext; + + // Visibility for tuning the limits on TEST/PROD. Partition key is the + // CAS login or proxy-resolved client IP, not raw user input. + httpContext.RequestServices + .GetRequiredService() + .CreateLogger(nameof(CmsDownloadRateLimiting)) + .LogInformation("Download rate limit hit for {PartitionKey} on {Path}", + GetPartitionKey(httpContext), + LogSanitizer.SanitizeString(httpContext.Request.Path)); + + // A response already in flight can't take headers or a new body. + if (httpContext.Response.HasStarted) + { + return; + } + + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + httpContext.Response.Headers.RetryAfter = + ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture); + } + httpContext.Response.ContentType = "text/plain"; + await httpContext.Response.WriteAsync( + "Too many download requests. Please wait a moment and try again.", ct); + }; + + options.AddPolicy(PolicyName, httpContext => + { + var key = GetPartitionKey(httpContext); + return key.StartsWith(ZipPrefix, StringComparison.Ordinal) + ? RateLimitPartition.GetTokenBucketLimiter(key, _ => new TokenBucketRateLimiterOptions + { + TokenLimit = zipTokenLimit, + ReplenishmentPeriod = TimeSpan.FromMinutes(1), + TokensPerPeriod = zipTokensPerMinute, + AutoReplenishment = true, + QueueLimit = 0 + }) + : RateLimitPartition.GetSlidingWindowLimiter(key, _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = filePermits, + Window = TimeSpan.FromMinutes(1), + SegmentsPerWindow = 6, + QueueLimit = 0 + }); + }); + }); + + return services; + } + + /// + /// Partition key: request type (zip vs single file, matching CMSController.Files' + /// dispatch on the ids parameter) plus the caller's login id, or client IP for + /// anonymous/public requests. + /// + public static string GetPartitionKey(HttpContext httpContext) + { + string prefix = IsZipRequest(httpContext) ? ZipPrefix : FilePrefix; + string? login = httpContext.User.Identity?.IsAuthenticated == true + ? httpContext.User.Identity.Name + : null; + string caller = !string.IsNullOrEmpty(login) + ? login + : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return prefix + caller; + } + + public static bool IsZipRequest(HttpContext httpContext) + { + return !string.IsNullOrEmpty(httpContext.Request.Query["ids"]); + } + } +} diff --git a/web/Program.cs b/web/Program.cs index 8f87dc1e7..669006b0f 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -27,6 +27,7 @@ using QuestPDF.Infrastructure; using Scrutor; using Viper; +using Viper.Areas.CMS.Services; using Viper.Areas.Effort; using Viper.Areas.Effort.Data; using Viper.Areas.Effort.Services.Harvest; @@ -96,6 +97,9 @@ // in Development. See ForwardedHeadersExtensions. builder.Services.AddViperForwardedHeaders(builder.Environment, logger); + // Rate limiting for CMS downloads (single files + ZIP archives). See CmsDownloadRateLimiting. + builder.Services.AddCmsDownloadRateLimiting(builder.Configuration); + // Add services to the container. builder.Services.AddControllersWithViews(options => { @@ -495,6 +499,9 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db // still before health checks, Hangfire, and controllers, which need an authenticated user. app.UseAuthentication(); app.UseAuthorization(); + // After auth so download rate-limit buckets can key on the logged-in user + // instead of the client IP (kinder to shared campus NAT). + app.UseRateLimiter(); app.UseCookiePolicy(); app.UseSession(); From c1aaae93c199794f396d67b52c136b72f9988cf2 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Fri, 12 Jun 2026 01:56:11 -0700 Subject: [PATCH 08/40] VPR-59 feat(cms): add CMS left nav and redesign hub with recent activity - Left nav now mirrors legacy CMS (Home, content blocks, files, left-nav sections, permission-gated); nav, hub cards, and page titles share "Manage X" wording, and /CMS/ canonicalizes to /CMS/Home so the Home link highlights - Hub: permission-filtered 2x2 tool cards plus a recent-activity rail (recently edited blocks/files deep-link to their editors via existing list APIs); zero-permission visitors get an info banner instead of a blank page - Import and Bulk Encrypt moved off the hub into a File Tools menu on Manage Files; the Upload File nav link opens the upload dialog via ?upload=1 - Shell a11y (all areas): visible focus ring on flat card actions, left nav items announce as links, drawer landmark labeled, new "Skip to section menu" link, and in-page anchor navigation no longer steals focus or scroll position - Shared router: scroll position survives query-only navigation, hash links scroll to their target; createSpaRouter.ts renamed to create-spa-router.ts (kebab convention) with new unit tests --- VueApp/src/CAHFS/router/index.ts | 2 +- VueApp/src/CMS/components/RecentActivity.vue | 145 +++++++++++++++++ VueApp/src/CMS/pages/BulkEncrypt.vue | 2 +- VueApp/src/CMS/pages/CmsHome.vue | 147 ++++++++++++------ VueApp/src/CMS/pages/ContentBlockEdit.vue | 2 +- VueApp/src/CMS/pages/ContentBlocks.vue | 2 +- VueApp/src/CMS/pages/FileAuditLog.vue | 2 +- VueApp/src/CMS/pages/Files.vue | 58 ++++++- VueApp/src/CMS/pages/ImportFiles.vue | 2 +- VueApp/src/CMS/pages/LeftNavEdit.vue | 2 +- VueApp/src/CMS/pages/LeftNavMenus.vue | 2 +- .../src/CMS/pages/ManageLinkCollections.vue | 2 +- VueApp/src/CMS/router/index.ts | 8 +- VueApp/src/CMS/router/routes.ts | 12 +- VueApp/src/CTS/router/index.ts | 2 +- VueApp/src/ClinicalScheduler/router/index.ts | 2 +- VueApp/src/Computing/router/index.ts | 2 +- .../src/Effort/components/EffortLeftNav.vue | 1 + VueApp/src/Effort/router/index.ts | 2 +- VueApp/src/Students/router/index.ts | 2 +- VueApp/src/composables/use-route-focus.ts | 7 +- VueApp/src/layouts/LeftNav.vue | 15 +- VueApp/src/layouts/ViperLayout.vue | 8 + .../__tests__/create-spa-router.test.ts | 81 ++++++++++ VueApp/src/shared/create-spa-router.ts | 29 ++++ VueApp/src/shared/createSpaRouter.ts | 20 --- VueApp/src/styles/base.css | 13 ++ web/Areas/CMS/Services/CmsNavMenu.cs | 43 ++++- 28 files changed, 522 insertions(+), 93 deletions(-) create mode 100644 VueApp/src/CMS/components/RecentActivity.vue create mode 100644 VueApp/src/shared/__tests__/create-spa-router.test.ts create mode 100644 VueApp/src/shared/create-spa-router.ts delete mode 100644 VueApp/src/shared/createSpaRouter.ts diff --git a/VueApp/src/CAHFS/router/index.ts b/VueApp/src/CAHFS/router/index.ts index 1ee7424d1..bf695865a 100644 --- a/VueApp/src/CAHFS/router/index.ts +++ b/VueApp/src/CAHFS/router/index.ts @@ -1,4 +1,4 @@ -import { createSpaRouter } from "@/shared/createSpaRouter" +import { createSpaRouter } from "@/shared/create-spa-router" import { routes } from "./routes" import { useRequireLogin } from "@/composables/RequireLogin" import { checkHasOnePermission } from "@/composables/CheckPagePermission" diff --git a/VueApp/src/CMS/components/RecentActivity.vue b/VueApp/src/CMS/components/RecentActivity.vue new file mode 100644 index 000000000..596166bc1 --- /dev/null +++ b/VueApp/src/CMS/components/RecentActivity.vue @@ -0,0 +1,145 @@ + + + diff --git a/VueApp/src/CMS/pages/BulkEncrypt.vue b/VueApp/src/CMS/pages/BulkEncrypt.vue index 5224965a2..365c10a4b 100644 --- a/VueApp/src/CMS/pages/BulkEncrypt.vue +++ b/VueApp/src/CMS/pages/BulkEncrypt.vue @@ -1,7 +1,7 @@