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/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/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; } + } +} 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); + } + } +}