From c0da0abda758cf93529434c8e5815c4e4fa4ea05 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 2 Jul 2026 19:20:06 -0700 Subject: [PATCH 1/4] VPR-59 feat(cms): file management, photos, import, and rate limiting API Slice 1/3 of the restacked CMS migration (built from the verified branch tip, so every slice carries the OS-independent path handling and the review fixes). Adds the files-domain backend: storage, CF-compatible crypto, audit, import, trash purge job, user photos, options lookups, the rebuilt /CMS/Files download path, and download rate limiting, with their unit tests. The content/nav backend and the management SPA follow in slices 2-3. --- test/CMS/CMSContentPermissionTests.cs | 178 +++ test/CMS/CMSFilesControllerTests.cs | 488 +++++++ test/CMS/CMSOptionsControllerTests.cs | 145 ++ test/CMS/CMSUserPhotoControllerTests.cs | 166 +++ test/CMS/CmsDownloadRateLimitingTests.cs | 88 ++ test/CMS/CmsFileCryptoTests.cs | 110 ++ test/CMS/CmsFileImportServiceTests.cs | 476 +++++++ test/CMS/CmsFileResponseTests.cs | 47 + test/CMS/CmsFileServiceTests.cs | 1171 +++++++++++++++++ test/CMS/CmsFileStorageServiceTests.cs | 323 +++++ test/CMS/CmsTrashPurgeScheduledJobTests.cs | 64 + test/CMS/CmsUserPhotoServiceTests.cs | 214 +++ test/CMS/MaxLengthEachAttributeTests.cs | 53 + test/CMS/SafeUrlAttributeTests.cs | 84 ++ .../Classes/Utilities/DateRangeHelperTests.cs | 56 + test/Services/HtmlSanitizerServiceTests.cs | 57 + web/Areas/CMS/Constants/CmsFileNaming.cs | 36 + web/Areas/CMS/Constants/CmsFileTypes.cs | 73 + web/Areas/CMS/Constants/CmsPermissions.cs | 20 + web/Areas/CMS/Constants/CmsTrash.cs | 20 + web/Areas/CMS/Controllers/CMSController.cs | 3 + .../CMS/Controllers/CMSFilesController.cs | 343 +++++ .../CMS/Controllers/CMSOptionsController.cs | 84 ++ .../CMS/Controllers/CMSUserPhotoController.cs | 76 ++ web/Areas/CMS/Data/CMS.cs | 260 ++-- .../CMS/Jobs/CmsTrashPurgeScheduledJob.cs | 51 + web/Areas/CMS/Models/CMSFile.cs | 8 +- web/Areas/CMS/Models/CmsFileMapper.cs | 44 + web/Areas/CMS/Models/DTOs/CmsFileDto.cs | 60 + .../CMS/Models/DTOs/CmsFileImportDtos.cs | 64 + web/Areas/CMS/Models/DTOs/CmsFileRequests.cs | 63 + web/Areas/CMS/Models/DTOs/CreateLinkDto.cs | 2 +- .../CMS/Services/CmsConcurrencyException.cs | 12 + .../CMS/Services/CmsDownloadRateLimiting.cs | 119 ++ web/Areas/CMS/Services/CmsFileAuditService.cs | 137 ++ web/Areas/CMS/Services/CmsFileCrypto.cs | 131 ++ .../CMS/Services/CmsFileEncryptionService.cs | 98 ++ .../CMS/Services/CmsFileImportService.cs | 415 ++++++ web/Areas/CMS/Services/CmsFilePathSafety.cs | 8 + web/Areas/CMS/Services/CmsFileResponse.cs | 28 + web/Areas/CMS/Services/CmsFileService.cs | 897 +++++++++++++ .../CMS/Services/CmsFileStorageService.cs | 337 +++++ web/Areas/CMS/Services/CmsServiceHelpers.cs | 41 + web/Areas/CMS/Services/CmsUserPhotoService.cs | 158 +++ .../CMS/Validation/MaxLengthEachAttribute.cs | 47 + web/Areas/CMS/Validation/SafeUrlAttribute.cs | 41 +- web/Classes/Utilities/DateRangeHelper.cs | 33 + web/Program.cs | 8 + web/Services/HtmlSanitizerService.cs | 49 +- web/Services/IHtmlSanitizerService.cs | 7 + web/Viper.csproj | 1 + web/appsettings.json | 6 + 52 files changed, 7307 insertions(+), 193 deletions(-) create mode 100644 test/CMS/CMSContentPermissionTests.cs create mode 100644 test/CMS/CMSFilesControllerTests.cs create mode 100644 test/CMS/CMSOptionsControllerTests.cs create mode 100644 test/CMS/CMSUserPhotoControllerTests.cs create mode 100644 test/CMS/CmsDownloadRateLimitingTests.cs create mode 100644 test/CMS/CmsFileCryptoTests.cs create mode 100644 test/CMS/CmsFileImportServiceTests.cs create mode 100644 test/CMS/CmsFileResponseTests.cs create mode 100644 test/CMS/CmsFileServiceTests.cs create mode 100644 test/CMS/CmsFileStorageServiceTests.cs create mode 100644 test/CMS/CmsTrashPurgeScheduledJobTests.cs create mode 100644 test/CMS/CmsUserPhotoServiceTests.cs create mode 100644 test/CMS/MaxLengthEachAttributeTests.cs create mode 100644 test/CMS/SafeUrlAttributeTests.cs create mode 100644 test/Classes/Utilities/DateRangeHelperTests.cs create mode 100644 web/Areas/CMS/Constants/CmsFileNaming.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/Constants/CmsTrash.cs create mode 100644 web/Areas/CMS/Controllers/CMSFilesController.cs create mode 100644 web/Areas/CMS/Controllers/CMSOptionsController.cs create mode 100644 web/Areas/CMS/Controllers/CMSUserPhotoController.cs create mode 100644 web/Areas/CMS/Jobs/CmsTrashPurgeScheduledJob.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/CmsFileImportDtos.cs create mode 100644 web/Areas/CMS/Models/DTOs/CmsFileRequests.cs create mode 100644 web/Areas/CMS/Services/CmsConcurrencyException.cs create mode 100644 web/Areas/CMS/Services/CmsDownloadRateLimiting.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/CmsFileImportService.cs create mode 100644 web/Areas/CMS/Services/CmsFileResponse.cs create mode 100644 web/Areas/CMS/Services/CmsFileService.cs create mode 100644 web/Areas/CMS/Services/CmsFileStorageService.cs create mode 100644 web/Areas/CMS/Services/CmsServiceHelpers.cs create mode 100644 web/Areas/CMS/Services/CmsUserPhotoService.cs create mode 100644 web/Areas/CMS/Validation/MaxLengthEachAttribute.cs create mode 100644 web/Classes/Utilities/DateRangeHelper.cs diff --git a/test/CMS/CMSContentPermissionTests.cs b/test/CMS/CMSContentPermissionTests.cs new file mode 100644 index 000000000..c557a8326 --- /dev/null +++ b/test/CMS/CMSContentPermissionTests.cs @@ -0,0 +1,178 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Viper.Areas.CMS.Constants; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; +using Viper.Models.RAPS; +using Viper.Models.VIPER; +using Viper.Services; +using DataCms = Viper.Areas.CMS.Data.CMS; + +namespace Viper.test.CMS; + +/// +/// Tests for Data.CMS.GetContentBlocksAllowed, the public-display permission gate behind the +/// CMS content endpoint (content/fn/{friendlyName}). A public block is served to anyone; a +/// restricted block is withheld unless the viewer is a CMS admin, the block has no permissions +/// and the viewer is logged in, or the viewer holds one of the block's permissions. Permission +/// scenarios are driven by mocking IUserHelper. +/// +public sealed class CMSContentPermissionTests : IDisposable +{ + private readonly VIPERContext _context; + private readonly RAPSContext _rapsContext; + private readonly IUserHelper _userHelper; + private readonly DataCms _cms; + + public CMSContentPermissionTests() + { + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + _rapsContext = new RAPSContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("RAPS_" + Guid.NewGuid()).Options); + var sanitizer = Substitute.For(); + sanitizer.Sanitize(Arg.Any()).Returns(c => c.ArgAt(0)); + _userHelper = Substitute.For(); + + _cms = new DataCms(_context, _rapsContext, sanitizer) { UserHelper = _userHelper }; + } + + public void Dispose() + { + _context.Dispose(); + _rapsContext.Dispose(); + } + + private async Task SeedBlockAsync(bool allowPublic, params string[] permissions) + { + var block = new ContentBlock + { + Content = "

secret

", + Title = "Block", + System = "Viper", + FriendlyName = "block-" + Guid.NewGuid().ToString("N")[..8], + AllowPublicAccess = allowPublic, + ModifiedOn = DateTime.Now, + ModifiedBy = "author" + }; + foreach (var permission in permissions) + { + block.ContentBlockToPermissions.Add(new ContentBlockToPermission { Permission = permission }); + } + _context.ContentBlocks.Add(block); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return block; + } + + private static AaudUser User() => new() { AaudUserId = 1, LoginId = "viewer", MothraId = "m1" }; + + private void GrantPermissions(AaudUser user, params string[] permissions) + { + _userHelper.GetAllPermissions(_rapsContext, user) + .Returns(permissions.Select(p => new TblPermission { Permission = p }).ToList()); + } + + [Fact] + public async Task PublicBlock_ReturnedToAnonymousUser() + { + var block = await SeedBlockAsync(allowPublic: true); + _userHelper.GetCurrentUser().ReturnsNull(); + + var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList(); + + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal(block.ContentBlockId, result[0].ContentBlockId); + } + + [Fact] + public async Task RestrictedBlock_WithheldFromUnprivilegedUser() + { + var block = await SeedBlockAsync(allowPublic: false, "SVMSecure.CATS"); + var user = User(); + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks).Returns(false); + _userHelper.HasPermission(_rapsContext, user, "SVMSecure").Returns(true); + // User holds an unrelated permission, not the block's required one. + GrantPermissions(user, "SVMSecure.Other"); + + var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList(); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task RestrictedBlock_ReturnedWhenUserHoldsBlockPermission() + { + var block = await SeedBlockAsync(allowPublic: false, "SVMSecure.CATS"); + var user = User(); + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks).Returns(false); + GrantPermissions(user, "SVMSecure.CATS"); + + var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList(); + + Assert.NotNull(result); + Assert.Single(result); + } + + [Fact] + public async Task RestrictedBlock_PermissionMatchIsCaseInsensitive() + { + var block = await SeedBlockAsync(allowPublic: false, "SVMSecure.CATS"); + var user = User(); + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks).Returns(false); + GrantPermissions(user, "svmsecure.cats"); + + var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList(); + + Assert.NotNull(result); + Assert.Single(result); + } + + [Fact] + public async Task CmsAdmin_SeesRestrictedBlock() + { + var block = await SeedBlockAsync(allowPublic: false, "SVMSecure.CATS"); + var user = User(); + _userHelper.GetCurrentUser().Returns(user); + // CMS admin override short-circuits before any block-permission match. + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks).Returns(true); + + var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList(); + + Assert.NotNull(result); + Assert.Single(result); + } + + [Fact] + public async Task NoPermissionBlock_ReturnedToAnyLoggedInUser() + { + // A block with no permissions is visible to any authenticated VIPER user (has "SVMSecure"). + var block = await SeedBlockAsync(allowPublic: false); + var user = User(); + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.ManageContentBlocks).Returns(false); + _userHelper.HasPermission(_rapsContext, user, "SVMSecure").Returns(true); + + var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList(); + + Assert.NotNull(result); + Assert.Single(result); + } + + [Fact] + public async Task RestrictedBlock_WithheldFromAnonymousUser() + { + var block = await SeedBlockAsync(allowPublic: false, "SVMSecure.CATS"); + _userHelper.GetCurrentUser().ReturnsNull(); + + var result = _cms.GetContentBlocksAllowed(null, block.FriendlyName, null, null, null, null, null, 1)?.ToList(); + + Assert.NotNull(result); + Assert.Empty(result); + } +} diff --git a/test/CMS/CMSFilesControllerTests.cs b/test/CMS/CMSFilesControllerTests.cs new file mode 100644 index 000000000..c1e5367f0 --- /dev/null +++ b/test/CMS/CMSFilesControllerTests.cs @@ -0,0 +1,488 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using NSubstitute.ExceptionExtensions; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Controllers; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; +using Viper.Classes.SQLContext; +using Viper.Models; +using Viper.Models.AAUD; + +namespace Viper.test.CMS; + +/// +/// Controller wiring tests for CMSFilesController: list/paging passthrough, get/upload/update +/// delete/restore status mapping (404 on null, 400 on bad input/conflict), import + preview + +/// bulk-encrypt empty-input guards, and admin-only permanent delete. The service behavior is +/// covered separately in CmsFileServiceTests / CmsFileImportServiceTests. +/// +public sealed class CMSFilesControllerTests : IDisposable +{ + private readonly ICmsFileService _fileService; + private readonly ICmsFileStorageService _storage; + private readonly ICmsFileAuditService _auditService; + private readonly ICmsFileImportService _importService; + private readonly RAPSContext _rapsContext; + private readonly IUserHelper _userHelper; + private readonly CMSFilesController _controller; + + public CMSFilesControllerTests() + { + _fileService = Substitute.For(); + _storage = Substitute.For(); + _auditService = Substitute.For(); + _importService = Substitute.For(); + _rapsContext = new RAPSContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("RAPS_" + Guid.NewGuid()).Options); + _userHelper = Substitute.For(); + + _controller = new CMSFilesController(_fileService, _storage, _auditService, _importService, _rapsContext, + Substitute.For>(), _userHelper); + SetupControllerContext(); + } + + // Streams backing form files must outlive the controller call; disposed with the test instance. + private readonly List _formFileStreams = new(); + + public void Dispose() + { + _rapsContext.Dispose(); + _formFileStreams.ForEach(s => s.Dispose()); + } + + private void SetupControllerContext() + { + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { RequestServices = serviceProvider } + }; + } + + private static CmsFileDto File(Guid? guid = null) => new() + { + FileGuid = guid ?? Guid.NewGuid(), + FileName = "report.pdf", + FriendlyName = "cats-report.pdf", + Folder = "cats" + }; + + private IFormFile MakeFormFile(string fileName = "report.pdf", long length = 3) + { + var stream = new MemoryStream(new byte[length]); + _formFileStreams.Add(stream); + return new FormFile(stream, 0, length, "file", fileName); + } + + #region List + + [Fact] + public async Task GetFiles_PassesFiltersAndPaginationThrough_AndSetsTotal() + { + // An admin sees the whole trash, so the owner restriction passed to the service is null. + var user = new AaudUser { AaudUserId = 1, LoginId = "admin" }; + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(true); + var files = new List { File() }; + _fileService.GetFilesAsync("cats", "deleted", "budget", true, false, 2, 25, "modifiedOn", true, + null, Arg.Any()).Returns((files, 42)); + var pagination = new ApiPagination { Page = 2, PerPage = 25 }; + + var result = await _controller.GetFiles("cats", "budget", true, false, pagination, "deleted", "modifiedOn", true, TestContext.Current.CancellationToken); + + Assert.Same(files, result.Value); + Assert.Equal(42, pagination.TotalRecords); + await _fileService.Received(1).GetFilesAsync("cats", "deleted", "budget", true, false, 2, 25, "modifiedOn", true, + null, Arg.Any()); + } + + [Fact] + public async Task GetFiles_DefaultsPageAndPerPage_WhenNoPagination() + { + var user = new AaudUser { AaudUserId = 1, LoginId = "user" }; + _userHelper.GetCurrentUser().Returns(user); + _fileService.GetFilesAsync(null, "active", null, null, null, 1, 50, "friendlyName", false, + "user", Arg.Any()).Returns((new List(), 0)); + + await _controller.GetFiles(null, null, null, null, null, ct: TestContext.Current.CancellationToken); + + await _fileService.Received(1).GetFilesAsync(null, "active", null, null, null, 1, 50, "friendlyName", false, + "user", Arg.Any()); + } + + [Fact] + public async Task GetFolders_DiskListByDefault_FilterListWhenIncludeData() + { + _storage.GetTopLevelFolders().Returns(new List { "cats" }); + _storage.GetFilterFoldersAsync(Arg.Any()).Returns(new List { "cats", "legacy" }); + + var disk = await _controller.GetFolders(includeData: false, TestContext.Current.CancellationToken); + var filter = await _controller.GetFolders(includeData: true, TestContext.Current.CancellationToken); + + Assert.Equal(new List { "cats" }, disk.Value); + Assert.Equal(new List { "cats", "legacy" }, filter.Value); + } + + [Fact] + public async Task GetAudit_PassesFilterAndPaginationThrough_AndSetsTotal() + { + _auditService.GetAuditEntriesAsync(Arg.Any(), 3, 10, Arg.Any()) + .Returns(new List()); + _auditService.GetAuditEntryCountAsync(Arg.Any(), Arg.Any()).Returns(5); + var fileGuid = Guid.NewGuid(); + var pagination = new ApiPagination { Page = 3, PerPage = 10 }; + + await _controller.GetAudit(fileGuid, "AccessFile", "loginX", null, null, "needle", pagination, TestContext.Current.CancellationToken); + + Assert.Equal(5, pagination.TotalRecords); + await _auditService.Received(1).GetAuditEntriesAsync( + Arg.Is(f => + f.FileGuid == fileGuid && f.Action == "AccessFile" && f.LoginId == "loginX" && f.Search == "needle"), + 3, 10, Arg.Any()); + } + + #endregion + + #region Get / CheckName + + [Fact] + public async Task GetFile_ReturnsFile_WhenFound() + { + var guid = Guid.NewGuid(); + _fileService.GetFileAsync(guid, Arg.Any()).Returns(File(guid)); + + var result = await _controller.GetFile(guid, TestContext.Current.CancellationToken); + + Assert.NotNull(result.Value); + Assert.Equal(guid, result.Value!.FileGuid); + } + + [Fact] + public async Task GetFile_ReturnsNotFound_WhenMissing() + { + _fileService.GetFileAsync(Arg.Any(), Arg.Any()).ReturnsNull(); + + var result = await _controller.GetFile(Guid.NewGuid(), TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task CheckName_ReturnsBadRequest_OnArgumentException() + { + _fileService.CheckNameAsync("nope", "report.pdf", Arg.Any()) + .Throws(new ArgumentException("Invalid folder.")); + + var result = await _controller.CheckName("nope", "report.pdf", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + #endregion + + #region Upload / Update + + [Fact] + public async Task UploadFile_ReturnsBadRequest_WhenNoFile() + { + var result = await _controller.UploadFile(new CmsFileCreateRequest { Folder = "cats" }, null, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + await _fileService.DidNotReceive().CreateFileAsync(Arg.Any(), Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task UploadFile_ReturnsFile_OnSuccess() + { + var request = new CmsFileCreateRequest { Folder = "cats" }; + var dto = File(); + _fileService.CreateFileAsync(request, Arg.Any(), Arg.Any()).Returns(dto); + + var result = await _controller.UploadFile(request, MakeFormFile(), TestContext.Current.CancellationToken); + + Assert.Same(dto, result.Value); + } + + [Fact] + public async Task UploadFile_ReturnsBadRequest_OnConflict() + { + var request = new CmsFileCreateRequest { Folder = "cats" }; + _fileService.CreateFileAsync(request, Arg.Any(), Arg.Any()) + .Throws(new InvalidOperationException("A file with that name already exists.")); + + var result = await _controller.UploadFile(request, MakeFormFile(), TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateFile_ReturnsNotFound_WhenServiceReturnsNull() + { + var guid = Guid.NewGuid(); + var request = new CmsFileUpdateRequest { Description = "x" }; + _fileService.UpdateFileAsync(guid, request, Arg.Any(), Arg.Any()).ReturnsNull(); + + var result = await _controller.UpdateFile(guid, request, null, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateFile_ReturnsBadRequest_OnArgumentException() + { + var guid = Guid.NewGuid(); + var request = new CmsFileUpdateRequest { Description = "x" }; + _fileService.UpdateFileAsync(guid, request, Arg.Any(), Arg.Any()) + .Throws(new ArgumentException("Replacement must keep the same extension.")); + + var result = await _controller.UpdateFile(guid, request, MakeFormFile("x.png"), TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task UpdateFile_ReturnsConflict_OnStaleEdit() + { + // CmsConcurrencyException derives from InvalidOperationException; it must map to 409, + // not fall into the generic InvalidOperationException -> 400 handler. + var guid = Guid.NewGuid(); + var request = new CmsFileUpdateRequest { Description = "x", LastModifiedOn = DateTime.Now.AddMinutes(-5) }; + _fileService.UpdateFileAsync(guid, request, Arg.Any(), Arg.Any()) + .Throws(new CmsConcurrencyException("This file was modified by someone on 7/2/2026.")); + + var result = await _controller.UpdateFile(guid, request, null, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + #endregion + + #region Delete / Restore + + [Fact] + public async Task DeleteFile_SoftDelete_ReturnsNoContent() + { + var guid = Guid.NewGuid(); + _fileService.SoftDeleteFileAsync(guid, Arg.Any()).Returns(true); + + var result = await _controller.DeleteFile(guid, permanent: false, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteFile_SoftDelete_ReturnsNotFound_WhenMissing() + { + _fileService.SoftDeleteFileAsync(Arg.Any(), Arg.Any()).Returns(false); + + var result = await _controller.DeleteFile(Guid.NewGuid(), permanent: false, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteFile_PermanentWithoutAdmin_ReturnsForbidden() + { + var user = new AaudUser { AaudUserId = 1, LoginId = "user" }; + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(false); + + var result = await _controller.DeleteFile(Guid.NewGuid(), permanent: true, TestContext.Current.CancellationToken); + + var objectResult = Assert.IsType(result); + Assert.Equal(StatusCodes.Status403Forbidden, objectResult.StatusCode); + await _fileService.DidNotReceive().PermanentlyDeleteFileAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DeleteFile_PermanentAsAdmin_ReturnsNoContent() + { + var guid = Guid.NewGuid(); + var user = new AaudUser { AaudUserId = 1, LoginId = "admin" }; + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(true); + _fileService.PermanentlyDeleteFileAsync(guid, Arg.Any()).Returns(true); + + var result = await _controller.DeleteFile(guid, permanent: true, TestContext.Current.CancellationToken); + + Assert.IsType(result); + await _fileService.Received(1).PermanentlyDeleteFileAsync(guid, Arg.Any()); + } + + [Fact] + public async Task RestoreFile_ReturnsNoContent_WhenRestored() + { + var guid = Guid.NewGuid(); + var user = new AaudUser { AaudUserId = 1, LoginId = "admin" }; + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(true); + _fileService.RestoreFileAsync(guid, Arg.Any()).Returns(true); + + var result = await _controller.RestoreFile(guid, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task RestoreFile_AllowsNonAdmin_WhoDeletedTheFile() + { + var guid = Guid.NewGuid(); + var user = new AaudUser { AaudUserId = 1, LoginId = "user" }; + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(false); + _fileService.GetFileAsync(guid, Arg.Any()) + .Returns(new CmsFileDto { FileGuid = guid, DeletedOn = DateTime.Now, ModifiedBy = "user" }); + _fileService.RestoreFileAsync(guid, Arg.Any()).Returns(true); + + var result = await _controller.RestoreFile(guid, TestContext.Current.CancellationToken); + + Assert.IsType(result); + await _fileService.Received(1).RestoreFileAsync(guid, Arg.Any()); + } + + [Fact] + public async Task RestoreFile_Forbids_WhenNonAdminDidNotDeleteIt() + { + var guid = Guid.NewGuid(); + var user = new AaudUser { AaudUserId = 1, LoginId = "user" }; + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(false); + _fileService.GetFileAsync(guid, Arg.Any()) + .Returns(new CmsFileDto { FileGuid = guid, DeletedOn = DateTime.Now, ModifiedBy = "someone-else" }); + + var result = await _controller.RestoreFile(guid, TestContext.Current.CancellationToken); + + var objectResult = Assert.IsType(result); + Assert.Equal(StatusCodes.Status403Forbidden, objectResult.StatusCode); + await _fileService.DidNotReceive().RestoreFileAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetFiles_ScopesDeletedToOwner_ForNonAdmin() + { + var user = new AaudUser { AaudUserId = 1, LoginId = "user" }; + _userHelper.GetCurrentUser().Returns(user); + _userHelper.HasPermission(_rapsContext, user, CmsPermissions.Admin).Returns(false); + _fileService.GetFilesAsync(null, "deleted", null, null, null, 1, 50, "friendlyName", false, + "user", Arg.Any()).Returns((new List(), 0)); + + await _controller.GetFiles(null, null, null, null, null, "deleted", ct: TestContext.Current.CancellationToken); + + // A non-admin's trash is scoped to the files they deleted (their login). + await _fileService.Received(1).GetFilesAsync(null, "deleted", null, null, null, 1, 50, "friendlyName", false, + "user", Arg.Any()); + } + + [Fact] + public async Task GetFiles_FailsClosed_WhenUserContextMissing() + { + _userHelper.GetCurrentUser().ReturnsNull(); + _fileService.GetFilesAsync(null, "deleted", null, null, null, 1, 50, "friendlyName", false, + string.Empty, Arg.Any()).Returns((new List(), 0)); + + await _controller.GetFiles(null, null, null, null, null, "deleted", ct: TestContext.Current.CancellationToken); + + // A missing user context must scope the trash to nothing, not fall through to the + // admin-level unrestricted (null) view. + await _fileService.Received(1).GetFilesAsync(null, "deleted", null, null, null, 1, 50, "friendlyName", false, + string.Empty, Arg.Any()); + } + + #endregion + + #region Import / Preview / Bulk-encrypt + + [Fact] + public async Task ImportFiles_ReturnsBadRequest_WhenNoPaths() + { + var result = await _controller.ImportFiles(new CmsFileImportRequest { Folder = "cats" }, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + await _importService.DidNotReceive().ImportFilesAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ImportFiles_PassesRequestThrough_OnSuccess() + { + var request = new CmsFileImportRequest { Folder = "cats", FilePaths = new List { "legacy/a.pdf" } }; + var results = new List { new() { FilePath = "legacy/a.pdf", Success = true } }; + _importService.ImportFilesAsync(request, Arg.Any()).Returns(results); + + var result = await _controller.ImportFiles(request, TestContext.Current.CancellationToken); + + Assert.Same(results, result.Value); + } + + [Fact] + public async Task ImportFiles_ReturnsBadRequest_OnInvalidOperation() + { + var request = new CmsFileImportRequest { Folder = "cats", FilePaths = new List { "legacy/a.pdf" } }; + _importService.ImportFilesAsync(request, Arg.Any()) + .Throws(new InvalidOperationException("Legacy webroot not configured.")); + + var result = await _controller.ImportFiles(request, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task PreviewImport_ReturnsBadRequest_WhenNoPaths() + { + var result = await _controller.PreviewImport(new CmsFileImportRequest { Folder = "cats" }, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task PreviewImport_PassesRequestThrough() + { + var request = new CmsFileImportRequest { Folder = "cats", FilePaths = new List { "legacy/a.pdf" } }; + var preview = new List { new() { FilePath = "legacy/a.pdf", CanImport = true } }; + _importService.PreviewImportAsync(request, Arg.Any()).Returns(preview); + + var result = await _controller.PreviewImport(request, TestContext.Current.CancellationToken); + + Assert.Same(preview, result.Value); + } + + [Fact] + public async Task BulkEncrypt_ReturnsBadRequest_WhenNoGuids() + { + var result = await _controller.BulkEncrypt(new List(), TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + await _importService.DidNotReceive().BulkEncryptAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task BulkEncrypt_ReturnsBadRequest_WhenOverBatchLimit() + { + var guids = Enumerable.Range(0, 501).Select(_ => Guid.NewGuid()).ToList(); + + var result = await _controller.BulkEncrypt(guids, TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + await _importService.DidNotReceive().BulkEncryptAsync(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task BulkEncrypt_PassesGuidsThrough() + { + var guids = new List { Guid.NewGuid() }; + var results = new List { new() { FileGuid = guids[0], Success = true } }; + _importService.BulkEncryptAsync(guids, Arg.Any()).Returns(results); + + var result = await _controller.BulkEncrypt(guids, TestContext.Current.CancellationToken); + + Assert.Same(results, result.Value); + } + + #endregion +} diff --git a/test/CMS/CMSOptionsControllerTests.cs b/test/CMS/CMSOptionsControllerTests.cs new file mode 100644 index 000000000..7d21e5038 --- /dev/null +++ b/test/CMS/CMSOptionsControllerTests.cs @@ -0,0 +1,145 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Controllers; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; +using Viper.Models.RAPS; + +namespace Viper.test.CMS; + +/// +/// Controller wiring tests for CMSOptionsController: the person search's minimum-length guard, +/// 25-result cap, name/id matching, and last-name/first-name ordering, plus the instance-scoped +/// permission list used by CMS tagging forms. +/// +public sealed class CMSOptionsControllerTests : IDisposable +{ + private readonly RAPSContext _rapsContext; + private readonly AAUDContext _aaudContext; + private readonly CMSOptionsController _controller; + + public CMSOptionsControllerTests() + { + _rapsContext = new RAPSContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("RAPS_" + Guid.NewGuid()).Options); + _aaudContext = new AAUDContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("AAUD_" + Guid.NewGuid()).Options); + + _controller = new CMSOptionsController(_rapsContext, _aaudContext); + } + + public void Dispose() + { + _rapsContext.Dispose(); + _aaudContext.Dispose(); + } + + private static AaudUser MakeUser(int id, string lastName, string firstName, string? loginId = null, + string? mailId = null, string? iamId = "iam", int current = 1) => new() + { + AaudUserId = id, + ClientId = "test-client", + MothraId = "m" + id, + LoginId = loginId, + MailId = mailId, + IamId = iamId, + Current = current, + LastName = lastName, + FirstName = firstName, + DisplayLastName = lastName, + DisplayFirstName = firstName, + DisplayFullName = lastName + ", " + firstName + }; + + #region SearchPeople + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("a")] + public async Task SearchPeople_ReturnsEmpty_WhenSearchBelowMinimumLength(string? search) + { + _aaudContext.AaudUsers.Add(MakeUser(1, "Smith", "Amy")); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.SearchPeople(search!, TestContext.Current.CancellationToken); + + Assert.NotNull(result.Value); + Assert.Empty(result.Value!); + } + + [Fact] + public async Task SearchPeople_CapsResultsAt25_WhenMoreThanLimitMatch() + { + for (var i = 0; i < 30; i++) + { + _aaudContext.AaudUsers.Add(MakeUser(i + 1, "Smith", $"Test{i:D2}")); + } + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.SearchPeople("Smith", TestContext.Current.CancellationToken); + + Assert.Equal(25, result.Value!.Count); + } + + [Fact] + public async Task SearchPeople_OrdersByDisplayLastName_ThenDisplayFirstName() + { + _aaudContext.AaudUsers.Add(MakeUser(1, "Zeta", "Amy", mailId: "orderx1")); + _aaudContext.AaudUsers.Add(MakeUser(2, "Alpha", "Bob", mailId: "orderx2")); + _aaudContext.AaudUsers.Add(MakeUser(3, "Mno", "Cara", mailId: "orderx3")); + _aaudContext.AaudUsers.Add(MakeUser(4, "Alpha", "Amy", mailId: "orderx4")); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.SearchPeople("orderx", TestContext.Current.CancellationToken); + + Assert.Equal(new[] { "Alpha, Amy", "Alpha, Bob", "Mno, Cara", "Zeta, Amy" }, + result.Value!.Select(p => p.Name)); + } + + [Fact] + public async Task SearchPeople_ExcludesNonCurrentAndMissingIamId() + { + _aaudContext.AaudUsers.Add(MakeUser(1, "Smith", "Amy", current: 0)); + _aaudContext.AaudUsers.Add(MakeUser(2, "Smith", "Bob", iamId: null)); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.SearchPeople("Smith", TestContext.Current.CancellationToken); + + Assert.Empty(result.Value!); + } + + [Fact] + public async Task SearchPeople_MatchesOnLoginIdAndMailId() + { + _aaudContext.AaudUsers.Add(MakeUser(1, "Doe", "Jane", loginId: "jdoe123", mailId: "jdoe")); + await _aaudContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var byLogin = await _controller.SearchPeople("jdoe123", TestContext.Current.CancellationToken); + var byMail = await _controller.SearchPeople("jdoe", TestContext.Current.CancellationToken); + + Assert.Single(byLogin.Value!); + Assert.Single(byMail.Value!); + } + + #endregion + + #region GetPermissions + + [Fact] + public async Task GetPermissions_ReturnsInstancePermissions_SortedAlphabetically_ExcludingVmacsAndViperForms() + { + _rapsContext.TblPermissions.AddRange( + new TblPermission { PermissionId = 1, Permission = "SVMSecure.CMS.Zeta" }, + new TblPermission { PermissionId = 2, Permission = "SVMSecure.CMS.Alpha" }, + new TblPermission { PermissionId = 3, Permission = "VMACS.Admin" }, + new TblPermission { PermissionId = 4, Permission = "VIPERForms.Admin" }); + await _rapsContext.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.GetPermissions(TestContext.Current.CancellationToken); + + Assert.Equal(new[] { "SVMSecure.CMS.Alpha", "SVMSecure.CMS.Zeta" }, result.Value); + } + + #endregion +} diff --git a/test/CMS/CMSUserPhotoControllerTests.cs b/test/CMS/CMSUserPhotoControllerTests.cs new file mode 100644 index 000000000..8b93f6da4 --- /dev/null +++ b/test/CMS/CMSUserPhotoControllerTests.cs @@ -0,0 +1,166 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Viper.Areas.CMS.Controllers; +using Viper.Areas.CMS.Services; + +namespace Viper.test.CMS; + +/// +/// Controller wiring tests for CMSUserPhotoController: each by-id-type endpoint forwards only +/// its identifier (and the altPhoto flag) to ICmsUserPhotoService and returns the bytes as an +/// image/jpeg file response with a private cache header; conditional requests (If-Modified-Since) +/// short-circuit to 304 per FIX 4. +/// +public sealed class CMSUserPhotoControllerTests +{ + private static readonly DateTimeOffset PhotoLastModified = + new(2026, 6, 1, 12, 0, 0, TimeSpan.Zero); + + private readonly ICmsUserPhotoService _photoService; + private readonly CMSUserPhotoController _controller; + + public CMSUserPhotoControllerTests() + { + _photoService = Substitute.For(); + _controller = new CMSUserPhotoController(_photoService); + + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { RequestServices = serviceProvider } + }; + } + + private static CmsUserPhotoResult Result(byte[] bytes) => new(bytes, PhotoLastModified); + + [Fact] + public async Task GetByMailId_ForwardsMailIdOnly() + { + var bytes = new byte[] { 1, 2, 3 }; + _photoService.GetUserPhotoAsync("mail@example.com", null, null, null, false, Arg.Any()) + .Returns(Result(bytes)); + + var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); + + var file = Assert.IsType(result); + Assert.Equal("image/jpeg", file.ContentType); + Assert.Equal(bytes, file.FileContents); + await _photoService.Received(1).GetUserPhotoAsync("mail@example.com", null, null, null, false, + Arg.Any()); + } + + [Fact] + public async Task GetByLoginId_ForwardsLoginIdAndAltPhotoFlag() + { + _photoService.GetUserPhotoAsync(null, "loginX", null, null, true, Arg.Any()) + .Returns(Result(new byte[] { 9 })); + + var result = await _controller.GetByLoginId("loginX", altPhoto: true, TestContext.Current.CancellationToken); + + Assert.IsType(result); + await _photoService.Received(1).GetUserPhotoAsync(null, "loginX", null, null, true, Arg.Any()); + } + + [Fact] + public async Task GetByIamId_ForwardsIamIdOnly() + { + _photoService.GetUserPhotoAsync(null, null, "1000123", null, false, Arg.Any()) + .Returns(Result(new byte[] { 7 })); + + var result = await _controller.GetByIamId("1000123", ct: TestContext.Current.CancellationToken); + + Assert.IsType(result); + await _photoService.Received(1).GetUserPhotoAsync(null, null, "1000123", null, false, Arg.Any()); + } + + [Fact] + public async Task GetByMothraId_ForwardsMothraIdOnly() + { + _photoService.GetUserPhotoAsync(null, null, null, "m-9001", false, Arg.Any()) + .Returns(Result(new byte[] { 5 })); + + var result = await _controller.GetByMothraId("m-9001", ct: TestContext.Current.CancellationToken); + + Assert.IsType(result); + await _photoService.Received(1).GetUserPhotoAsync(null, null, null, "m-9001", false, Arg.Any()); + } + + [Fact] + public async Task ServePhoto_SetsPrivateCacheHeader() + { + _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1 })); + + await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); + + var cacheControl = _controller.Response.Headers.CacheControl.ToString(); + Assert.Contains("private", cacheControl); + Assert.Contains("stale-while-revalidate", cacheControl); + } + + [Fact] + public async Task ServePhoto_SetsLastModifiedHeader() + { + _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1 })); + + await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); + + Assert.Equal(PhotoLastModified, _controller.Response.GetTypedHeaders().LastModified); + } + + [Fact] + public async Task ServePhoto_ReturnsNotModified_WhenIfModifiedSinceCoversLastModified() + { + _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 })); + _controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified; + + var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); + + var status = Assert.IsType(result); + Assert.Equal(StatusCodes.Status304NotModified, status.StatusCode); + } + + [Fact] + public async Task ServePhoto_ReturnsNotModified_WhenIfModifiedSinceIsAfterLastModified() + { + _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()).Returns(Result(new byte[] { 1, 2, 3 })); + _controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified.AddDays(1); + + var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); + + var status = Assert.IsType(result); + Assert.Equal(StatusCodes.Status304NotModified, status.StatusCode); + } + + [Fact] + public async Task ServePhoto_ReturnsFile_WhenIfModifiedSinceIsBeforeLastModified() + { + var bytes = new byte[] { 1, 2, 3 }; + _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()).Returns(Result(bytes)); + _controller.Request.GetTypedHeaders().IfModifiedSince = PhotoLastModified.AddDays(-1); + + var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); + + var file = Assert.IsType(result); + Assert.Equal(bytes, file.FileContents); + } + + [Fact] + public async Task ServePhoto_ReturnsFile_WhenNoIfModifiedSinceHeaderSent() + { + var bytes = new byte[] { 1, 2, 3 }; + _photoService.GetUserPhotoAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()).Returns(Result(bytes)); + + var result = await _controller.GetByMailId("mail@example.com", ct: TestContext.Current.CancellationToken); + + var file = Assert.IsType(result); + Assert.Equal(bytes, file.FileContents); + } +} 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/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/CmsFileImportServiceTests.cs b/test/CMS/CmsFileImportServiceTests.cs new file mode 100644 index 000000000..bbc16a42c --- /dev/null +++ b/test/CMS/CmsFileImportServiceTests.cs @@ -0,0 +1,476 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +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); + // Default: available name echoes the input (no rename needed). + _storage.GetAvailableFileName(Arg.Any(), Arg.Any()) + .Returns(callInfo => callInfo.ArgAt(1)); + _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, Substitute.For>()); + } + + public void Dispose() + { + _context.Dispose(); + if (Directory.Exists(_webroot)) + { + Directory.Delete(_webroot, recursive: true); + } + } + + private string CreateWebrootFile(string relativePath, string contents = "legacy file") + { + // Split on both separator styles: on Linux a raw @"cats\docs\x.pdf" would otherwise + // become one literal file name in the webroot root instead of a nested path. + var segments = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); + var path = Path.Join(_webroot, Path.Join(segments)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + 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); + } + + #region Preview + + [Fact] + public async Task Preview_ValidFile_ReportsNamesWithoutMoving() + { + var source = CreateWebrootFile(@"cats\docs\manual.pdf"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "/cats/docs/manual.pdf" }, + Folder = "cats" + }; + + var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.True(result.CanImport, result.Message); + Assert.Equal("manual.pdf", result.FileName); + Assert.Equal("cats-manual.pdf", result.FriendlyName); + Assert.Equal("/cats/docs/manual.pdf", result.OldUrl); + Assert.Null(result.RenamedFrom); + // Source must not be moved or deleted. + Assert.True(File.Exists(source)); + // No DB rows must be created. + Assert.Empty(await _context.Files.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Preview_MissingFile_ReportsNotFound() + { + var request = new CmsFileImportRequest + { + FilePaths = new List { "cats/docs/missing.pdf" }, + Folder = "cats" + }; + + var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.False(result.CanImport); + Assert.Contains("not found", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Preview_TraversalPath_ReportsOutsideWebroot() + { + var request = new CmsFileImportRequest + { + FilePaths = new List { "../outside.pdf" }, + Folder = "cats" + }; + + var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.False(result.CanImport); + Assert.Contains("outside", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Preview_DuplicateLines_FlagsSecondAsDuplicate() + { + CreateWebrootFile(@"cats\docs\manual.pdf"); + var request = new CmsFileImportRequest + { + FilePaths = new List + { + "cats/docs/manual.pdf", + "cats/docs/manual.pdf" + }, + Folder = "cats" + }; + + var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(2, results.Count); + Assert.True(results[0].CanImport); + Assert.False(results[1].CanImport); + Assert.Contains("Duplicate", results[1].Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Preview_ExistingFriendlyName_Blocks() + { + CreateWebrootFile(@"cats\docs\manual.pdf"); + + // Seed a Files row whose FriendlyName matches what the preview would compute. + _context.Files.Add(new Viper.Models.VIPER.File + { + FileGuid = Guid.NewGuid(), + FilePath = @"C:\FakeRoot\cats\manual.pdf", + Folder = "cats", + FriendlyName = "cats-manual.pdf", + Description = "existing", + ModifiedBy = "test", + ModifiedOn = DateTime.Now + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new CmsFileImportRequest + { + FilePaths = new List { "cats/docs/manual.pdf" }, + Folder = "cats" + }; + + var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.False(result.CanImport); + Assert.Contains("cats-manual.pdf", result.Message); + } + + [Fact] + public async Task Preview_InvalidPathCharacters_ReportsInvalidPath() + { + var request = new CmsFileImportRequest + { + FilePaths = new List { "cats/bad\0name.pdf" }, + Folder = "cats" + }; + + var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.False(result.CanImport); + Assert.Contains("not valid", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Preview_DisallowedFileType_ReportsNotAllowed() + { + CreateWebrootFile(@"cats\evil.bat", "nope"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "cats/evil.bat" }, + Folder = "cats" + }; + + var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.False(result.CanImport); + Assert.Contains("not allowed", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Preview_DiskConflict_ReportsAutoRename() + { + CreateWebrootFile(@"cats\docs\manual.pdf"); + _storage.GetAvailableFileName("cats", "manual.pdf").Returns("manual_0.pdf"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "cats/docs/manual.pdf" }, + Folder = "cats" + }; + + var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.True(result.CanImport, result.Message); + Assert.Equal("manual_0.pdf", result.FileName); + Assert.Equal("manual.pdf", result.RenamedFrom); + Assert.Equal("cats-manual_0.pdf", result.FriendlyName); + } + + [Fact] + public async Task Preview_TwoLinesSameBasename_SecondPreviewsImportRenamedName() + { + CreateWebrootFile(@"cats\docs\manual.pdf"); + // A second file in a different sub-path that produces the same final name after rename. + Directory.CreateDirectory(Path.Join(_webroot, "cats", "other")); + CreateWebrootFile(@"cats\other\manual.pdf"); + + // Disk/DB alone leaves the name free, so both lines resolve to "manual.pdf" on their own. + _storage.GetAvailableFileName("cats", "manual.pdf").Returns("manual.pdf"); + // But once the first line reserves "manual.pdf", the import would rename the second; the + // preview must reserve names across the batch and show that same renamed name up front. + _storage.GetAvailableFileName("cats", "manual.pdf", + Arg.Is?>(s => s != null && s.Contains("manual.pdf"))).Returns("manual_0.pdf"); + + var request = new CmsFileImportRequest + { + FilePaths = new List + { + "cats/docs/manual.pdf", + "cats/other/manual.pdf" + }, + Folder = "cats" + }; + + var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(2, results.Count); + // First entry: keeps the name, no message. + Assert.True(results[0].CanImport); + Assert.Equal("manual.pdf", results[0].FileName); + Assert.Null(results[0].Message); + // Second entry: previews the exact renamed name the import will assign (not the colliding + // name), with a non-blocking message explaining the rename. + Assert.True(results[1].CanImport); + Assert.Equal("manual_0.pdf", results[1].FileName); + Assert.Equal("manual.pdf", results[1].RenamedFrom); + Assert.Equal("cats-manual_0.pdf", results[1].FriendlyName); + Assert.NotNull(results[1].Message); + Assert.Contains("renamed", results[1].Message, StringComparison.OrdinalIgnoreCase); + } + + #endregion + + [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); + } + + [Fact] + public async Task BulkEncrypt_NonDatabaseSaveFailure_DecryptsFileBack() + { + // Named in-memory stores are shared, so the seeding context and the failing + // context (whose interceptor throws a non-DB exception on save) see the same data. + var storeName = "VIPER_" + Guid.NewGuid(); + 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 + }; + await using (var seedContext = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(storeName).Options)) + { + seedContext.Files.Add(plain); + await seedContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var failingContext = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(storeName) + .AddInterceptors(new ThrowingSaveInterceptor()) + .Options); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["CMS:LegacyWebrootPath"] = _webroot }) + .Build(); + var service = new CmsFileImportService(failingContext, _storage, _encryption, _audit, + Substitute.For(), configuration, Substitute.For>()); + _storage.ManagedFileExists(plain.FilePath).Returns(true); + + var results = await service.BulkEncryptAsync(new List { plain.FileGuid }, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.Contains("simulated save failure", result.Message); + _encryption.Received(1).DecryptFileInPlace(plain.FilePath, "encrypted-db-key"); + } + + private sealed class ThrowingSaveInterceptor : SaveChangesInterceptor + { + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("simulated save failure"); + } + } +} diff --git a/test/CMS/CmsFileResponseTests.cs b/test/CMS/CmsFileResponseTests.cs new file mode 100644 index 000000000..b199b3dc5 --- /dev/null +++ b/test/CMS/CmsFileResponseTests.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Services; + +namespace Viper.test.CMS; + +/// +/// Tests for the /CMS/Files download-response hardening: X-Content-Type-Options: nosniff on every +/// file response, and forcing inline-unsafe types (html/svg) to download instead of rendering in +/// the app origin (stored-XSS guard). Legacy .html files stay downloadable, just never inline. +/// +public sealed class CmsFileResponseTests +{ + [Fact] + public void SetNoSniff_SetsHeader() + { + var response = new DefaultHttpContext().Response; + + CmsFileResponse.SetNoSniff(response); + + Assert.Equal("nosniff", response.Headers["X-Content-Type-Options"]); + } + + [Theory] + [InlineData("page.html")] + [InlineData("PAGE.HTM")] + [InlineData("doc.xhtml")] + [InlineData("logo.svg")] + [InlineData(@"S:\Files\cats\evil.HTML")] + public void DispositionType_InlineUnsafe_ForcesAttachment(string fileName) + { + Assert.Equal("attachment", CmsFileResponse.DispositionType(fileName)); + Assert.True(CmsFileTypes.ShouldForceAttachment(fileName)); + } + + [Theory] + [InlineData("report.pdf")] + [InlineData("image.png")] + [InlineData("notes.txt")] + [InlineData("sheet.xlsx")] + [InlineData("noextension")] + public void DispositionType_SafeTypes_StayInline(string fileName) + { + Assert.Equal("inline", CmsFileResponse.DispositionType(fileName)); + Assert.False(CmsFileTypes.ShouldForceAttachment(fileName)); + } +} diff --git a/test/CMS/CmsFileServiceTests.cs b/test/CMS/CmsFileServiceTests.cs new file mode 100644 index 000000000..601e19e06 --- /dev/null +++ b/test/CMS/CmsFileServiceTests.cs @@ -0,0 +1,1171 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; +using Viper.Classes.SQLContext; +using Viper.Models.VIPER; +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; + private readonly List _extraContexts = new(); + private readonly List _formFileStreams = new(); + + 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(_ => 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, + Substitute.For>()); + } + + public void Dispose() + { + _context.Dispose(); + _aaudContext.Dispose(); + foreach (var ctx in _extraContexts) + { + ctx.Dispose(); + } + foreach (var stream in _formFileStreams) + { + stream.Dispose(); + } + } + + private IFormFile MakeFormFile(string fileName = "report.pdf") + { + // Tracked so the backing stream is disposed in Dispose rather than left to the GC. + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + _formFileStreams.Add(stream); + return new FormFile(stream, 0, stream.Length, "file", fileName); + } + + /// + /// Writes a real temp file to stand in for a backup the service returns from BackupManagedFile. + /// A real path lets the cleanup branch (System.IO.File.Exists/Delete) actually execute rather + /// than short-circuiting on a fake path that never exists. Callers delete it in a finally. + /// + private static string MakeRealBackupFile() + { + string path = Path.Join(Path.GetTempPath(), "Viper-CMS-Test-" + Guid.NewGuid().ToString("N")); + System.IO.File.WriteAllBytes(path, new byte[] { 9, 9, 9 }); + return path; + } + + // Shared seed timestamp so update requests can present a matching LastModifiedOn + // (UpdateFileAsync rejects stale or missing values with 409/400 semantics). + private readonly DateTime _seededModifiedOn = DateTime.Now.AddDays(-1); + + 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 = _seededModifiedOn + }; + customize?.Invoke(file); + _context.Files.Add(file); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + return file; + } + + /// + /// Seeds a file into a shared named store, then returns a service backed by a second context + /// on that store whose interceptor throws on save. Lets the rollback path be exercised without + /// a real database. Reuses the mocked storage/encryption so their calls can be asserted. + /// + private async Task<(CmsFileService Service, Guid FileGuid, string FilePath)> BuildServiceOverFailingSaveAsync( + Action? customize = null) + { + const string filePath = @"C:\FakeRoot\cats\plain.pdf"; + var storeName = "VIPER_" + Guid.NewGuid(); + var file = new File + { + FileGuid = Guid.NewGuid(), + FilePath = filePath, + Folder = "cats", + FriendlyName = "cats-plain.pdf", + Description = "seeded", + ModifiedBy = "test", + ModifiedOn = _seededModifiedOn + }; + customize?.Invoke(file); + await using (var seedContext = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(storeName).Options)) + { + seedContext.Files.Add(file); + await seedContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + var failingContext = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(storeName) + .AddInterceptors(new ThrowingSaveInterceptor()) + .Options); + _extraContexts.Add(failingContext); + var service = new CmsFileService(failingContext, _aaudContext, _storage, _encryption, _audit, _userHelper, + Substitute.For>()); + return (service, file.FileGuid, filePath); + } + + private sealed class ThrowingSaveInterceptor : SaveChangesInterceptor + { + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("simulated save failure"); + } + } + + /// + /// Throws only when the save being intercepted deletes the given file, so a purge run's other + /// per-file saves succeed normally. Lets PurgeDeletedFilesAsync's per-file isolation be tested + /// without a database that can produce a real transient failure. + /// + private sealed class SelectiveThrowingSaveInterceptor : SaveChangesInterceptor + { + private readonly Guid _failingFileGuid; + + public SelectiveThrowingSaveInterceptor(Guid failingFileGuid) + { + _failingFileGuid = failingFileGuid; + } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, + InterceptionResult result, CancellationToken cancellationToken = default) + { + bool targetsFailingFile = eventData.Context?.ChangeTracker.Entries() + .Any(e => e.State == EntityState.Deleted && e.Entity.FileGuid == _failingFileGuid) ?? false; + if (targetsFailingFile) + { + throw new InvalidOperationException("simulated transient save failure"); + } + return ValueTask.FromResult(result); + } + } + + #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_NormalizesForwardSlashFolderToCanonicalForm() + { + // IsValidFolder accepts both separator styles, but records store the canonical + // '\' form so the list filter's "folder\" prefix match finds subfolder rows. + var request = new CmsFileCreateRequest { Folder = "cats/photos" }; + + await _service.CreateFileAsync(request, MakeFormFile("pic.jpg"), TestContext.Current.CancellationToken); + + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal(@"cats\photos", saved.Folder); + } + + [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(), TestContext.Current.CancellationToken)); + + _storage.Received(1).DeleteManagedFile(Path.Join(@"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)); + } + + [Fact] + public async Task CreateFile_FileNameOverride_StoresUnderOverrideName() + { + var request = new CmsFileCreateRequest { Folder = "cats", FileName = "custom.pdf" }; + + var dto = await _service.CreateFileAsync(request, MakeFormFile("orig.pdf"), TestContext.Current.CancellationToken); + + // MoveIntoPlace should have been called with the override name, not the upload name. + _storage.Received(1).MoveIntoPlace(Arg.Any(), "cats", "custom.pdf", Arg.Any()); + Assert.Equal("custom.pdf", dto.FileName); + Assert.Equal("cats-custom.pdf", dto.FriendlyName); + } + + [Fact] + public async Task CreateFile_OverwriteDiskOnly_ReplacesInPlace() + { + const string targetPath = @"C:\FakeRoot\cats\report.pdf"; + _storage.FileNameInUse("cats", "report.pdf").Returns(true); + _storage.BuildManagedPath("cats", "report.pdf").Returns(targetPath); + + var request = new CmsFileCreateRequest { Folder = "cats", Overwrite = true }; + + await _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken); + + _storage.Received(1).ReplaceInPlace(Arg.Any(), targetPath); + _storage.DidNotReceive().MoveIntoPlace(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal(targetPath, saved.FilePath); + } + + [Fact] + public async Task CreateFile_OverwriteManagedRecord_Throws() + { + const string targetPath = @"C:\FakeRoot\cats\report.pdf"; + _storage.FileNameInUse("cats", "report.pdf").Returns(true); + _storage.BuildManagedPath("cats", "report.pdf").Returns(targetPath); + + // Seed a Files row whose FilePath matches the target path. + await SeedFileAsync(f => + { + f.FilePath = targetPath; + f.FriendlyName = "cats-report.pdf"; + }); + + var request = new CmsFileCreateRequest { Folder = "cats", Overwrite = true }; + + await Assert.ThrowsAsync( + () => _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken)); + + _storage.DidNotReceive().ReplaceInPlace(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CreateFile_OverwriteWithoutConflict_StoresNormally() + { + var request = new CmsFileCreateRequest { Folder = "cats", Overwrite = true }; + + await _service.CreateFileAsync(request, MakeFormFile(), TestContext.Current.CancellationToken); + + _storage.Received(1).MoveIntoPlace(Arg.Any(), "cats", "report.pdf", Arg.Any()); + _storage.DidNotReceive().ReplaceInPlace(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CreateFile_OverwriteOrphan_SaveFails_RestoresOriginalFile() + { + const string targetPath = @"C:\FakeRoot\cats\orphan.pdf"; + const string backupPath = @"C:\FakeTemp\backup-orphan"; + _storage.FileNameInUse("cats", "orphan.pdf").Returns(true); + _storage.BuildManagedPath("cats", "orphan.pdf").Returns(targetPath); + _storage.ManagedFileExists(targetPath).Returns(true); + _storage.BackupManagedFile(targetPath).Returns(backupPath); + + var (service, _, _) = await BuildServiceOverFailingSaveAsync(); + var request = new CmsFileCreateRequest { Folder = "cats", Overwrite = true }; + + await Assert.ThrowsAsync( + () => service.CreateFileAsync(request, MakeFormFile("orphan.pdf"), TestContext.Current.CancellationToken)); + + // The orphaned original (no DB row) was overwritten on disk; the failed save must move the + // pre-overwrite backup back into place rather than deleting it, so the file isn't lost. + _storage.Received(1).BackupManagedFile(targetPath); + _storage.Received(1).ReplaceInPlace(backupPath, targetPath); + _storage.DidNotReceive().DeleteManagedFile(targetPath); + } + + [Fact] + public async Task CreateFile_OverwriteOrphan_SaveFails_RestoreFails_PreservesBackup() + { + const string targetPath = @"C:\FakeRoot\cats\orphan.pdf"; + _storage.FileNameInUse("cats", "orphan.pdf").Returns(true); + _storage.BuildManagedPath("cats", "orphan.pdf").Returns(targetPath); + _storage.ManagedFileExists(targetPath).Returns(true); + + // A real temp file stands in for the backup so the cleanup branch (File.Exists/Delete) + // actually runs instead of short-circuiting on a non-existent fake path. + string backupPath = MakeRealBackupFile(); + try + { + _storage.BackupManagedFile(targetPath).Returns(backupPath); + // The restore attempt itself fails (e.g., the target is locked); the backup is then the + // only surviving copy of the original bytes and must not be deleted. + _storage.When(s => s.ReplaceInPlace(backupPath, targetPath)) + .Do(_ => throw new IOException("restore failed")); + + var (service, _, _) = await BuildServiceOverFailingSaveAsync(); + var request = new CmsFileCreateRequest { Folder = "cats", Overwrite = true }; + + await Assert.ThrowsAsync( + () => service.CreateFileAsync(request, MakeFormFile("orphan.pdf"), TestContext.Current.CancellationToken)); + + _storage.Received(1).ReplaceInPlace(backupPath, targetPath); + Assert.True(System.IO.File.Exists(backupPath)); + } + finally + { + if (System.IO.File.Exists(backupPath)) + { + System.IO.File.Delete(backupPath); + } + } + } + + #endregion + + #region Update + + [Fact] + public async Task UpdateFile_AppliesPermissionDeltas() + { + var file = await SeedFileAsync(f => + { + f.FileToPermissions.Add(new FileToPermission { FileGuid = f.FileGuid, Permission = "SVMSecure.Old" }); + f.FileToPermissions.Add(new FileToPermission { FileGuid = f.FileGuid, Permission = "SVMSecure.Keep" }); + }); + var request = new CmsFileUpdateRequest + { + Description = "seeded", + LastModifiedOn = _seededModifiedOn, + 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 FileToPerson { FileGuid = f.FileGuid, IamId = "100OLD" })); + var request = new CmsFileUpdateRequest + { + Description = "seeded", + LastModifiedOn = _seededModifiedOn, + 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, LastModifiedOn = _seededModifiedOn }; + + 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, LastModifiedOn = _seededModifiedOn }; + + 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", LastModifiedOn = _seededModifiedOn }; + + 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_ReplacementWithDifferentExtension_Throws() + { + // The record keeps its name/path on replacement, so a .png swapped for the seeded + // .pdf would leave the stored .pdf holding .png bytes; the upload must be rejected. + var file = await SeedFileAsync(); + var request = new CmsFileUpdateRequest { Description = "seeded", LastModifiedOn = _seededModifiedOn }; + + await Assert.ThrowsAsync( + () => _service.UpdateFileAsync(file.FileGuid, request, MakeFormFile("newversion.png"), TestContext.Current.CancellationToken)); + _storage.DidNotReceive().ReplaceInPlace(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateFile_WithReplacementUpload_RestoresSoftDeletedFile() + { + var file = await SeedFileAsync(f => f.DeletedOn = DateTime.Now); + var request = new CmsFileUpdateRequest { Description = "seeded", LastModifiedOn = _seededModifiedOn }; + + await _service.UpdateFileAsync(file.FileGuid, request, MakeFormFile("newversion.pdf"), TestContext.Current.CancellationToken); + + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.Null(saved.DeletedOn); + _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.EditFile, + Arg.Is(d => d.Contains("Restored file"))); + } + + [Fact] + public async Task UpdateFile_MetadataOnly_DoesNotRestoreSoftDeletedFile() + { + var file = await SeedFileAsync(f => f.DeletedOn = DateTime.Now); + var request = new CmsFileUpdateRequest { Description = "updated", LastModifiedOn = _seededModifiedOn }; + + await _service.UpdateFileAsync(file.FileGuid, request, null, TestContext.Current.CancellationToken); + + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.NotNull(saved.DeletedOn); + } + + [Fact] + public async Task UpdateFile_UnknownGuid_ReturnsNull() + { + var dto = await _service.UpdateFileAsync(Guid.NewGuid(), new CmsFileUpdateRequest(), null, TestContext.Current.CancellationToken); + + Assert.Null(dto); + } + + [Fact] + public async Task UpdateFile_StaleLastModifiedOn_ThrowsConcurrency_BeforeAnyChange() + { + var file = await SeedFileAsync(); + var request = new CmsFileUpdateRequest + { + Description = "changed", + LastModifiedOn = _seededModifiedOn.AddMinutes(-5) + }; + + var ex = await Assert.ThrowsAsync( + () => _service.UpdateFileAsync(file.FileGuid, request, null, TestContext.Current.CancellationToken)); + + // Names who saved and when, and nothing was mutated or audited. + Assert.Contains("modified by test", ex.Message); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal("seeded", saved.Description); + _audit.DidNotReceive().Audit(Arg.Any(), CmsFileAuditActions.EditFile, Arg.Any()); + } + + [Fact] + public async Task UpdateFile_MissingLastModifiedOn_ThrowsArgumentException() + { + var file = await SeedFileAsync(); + var request = new CmsFileUpdateRequest { Description = "changed" }; + + await Assert.ThrowsAsync( + () => _service.UpdateFileAsync(file.FileGuid, request, null, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CheckName_RecordUnderAnotherStorageRoot_MatchesByFriendlyName() + { + // A record created in another environment carries that root in FilePath, but its + // derived friendly name is stable; the conflict payload must identify the record so + // the client offers "edit that file" rather than a plain disk overwrite. + var file = await SeedFileAsync(f => + { + f.FilePath = @"S:\Files\cats\foreign.pdf"; + f.FriendlyName = "cats-foreign.pdf"; + }); + _storage.IsValidFolder("cats").Returns(true); + _storage.BuildManagedPath("cats", "foreign.pdf").Returns(@"C:\FakeRoot\cats\foreign.pdf"); + + var check = await _service.CheckNameAsync("cats", "foreign.pdf", TestContext.Current.CancellationToken); + + Assert.Equal(file.FileGuid, check.ExistingFileGuid); + Assert.Equal("cats-foreign.pdf", check.ExistingFriendlyName); + } + + [Fact] + public async Task CreateFile_OverwriteOfForeignRootRecord_FailsBeforeTouchingDisk() + { + await SeedFileAsync(f => + { + f.FilePath = @"S:\Files\cats\foreign.pdf"; + f.FriendlyName = "cats-foreign.pdf"; + }); + _storage.FileNameInUse("cats", "foreign.pdf").Returns(true); + _storage.BuildManagedPath("cats", "foreign.pdf").Returns(@"C:\FakeRoot\cats\foreign.pdf"); + var request = new CmsFileCreateRequest { Folder = "cats", Overwrite = true }; + + await Assert.ThrowsAsync( + () => _service.CreateFileAsync(request, MakeFormFile("foreign.pdf"), TestContext.Current.CancellationToken)); + + // The guard must fire before any disk mutation. + _storage.DidNotReceive().ReplaceInPlace(Arg.Any(), Arg.Any()); + _storage.DidNotReceive().BackupManagedFile(Arg.Any()); + } + + [Fact] + public async Task CheckName_ExistingRecord_ReturnsItsModifiedOn() + { + // The overwrite flow echoes this back as LastModifiedOn, so it must be the record's value. + var file = await SeedFileAsync(); + _storage.IsValidFolder("cats").Returns(true); + _storage.BuildManagedPath("cats", "seeded.pdf").Returns(file.FilePath); + + var check = await _service.CheckNameAsync("cats", "seeded.pdf", TestContext.Current.CancellationToken); + + Assert.Equal(file.FileGuid, check.ExistingFileGuid); + Assert.Equal(_seededModifiedOn, check.ExistingModifiedOn); + } + + [Fact] + public async Task UpdateFile_EncryptToggleOn_SaveFails_DecryptsFileBack() + { + var (service, fileGuid, filePath) = await BuildServiceOverFailingSaveAsync(); + _storage.ManagedFileExists(filePath).Returns(true); + var request = new CmsFileUpdateRequest { Description = "seeded", Encrypt = true, LastModifiedOn = _seededModifiedOn }; + + await Assert.ThrowsAsync( + () => service.UpdateFileAsync(fileGuid, request, null, TestContext.Current.CancellationToken)); + + // The file was encrypted on disk but the save failed, so it must be decrypted back with + // the same key or it would be unreadable (no key persisted). + _encryption.Received(1).DecryptFileInPlace(filePath, "encrypted-db-key"); + } + + [Fact] + public async Task UpdateFile_EncryptToggleOff_SaveFails_ReEncryptsFile() + { + var (service, fileGuid, filePath) = await BuildServiceOverFailingSaveAsync(f => + { + f.Encrypted = true; + f.Key = "old-key"; + }); + _storage.ManagedFileExists(filePath).Returns(true); + var request = new CmsFileUpdateRequest { Description = "seeded", Encrypt = false, LastModifiedOn = _seededModifiedOn }; + + await Assert.ThrowsAsync( + () => service.UpdateFileAsync(fileGuid, request, null, TestContext.Current.CancellationToken)); + + // The file was decrypted on disk but the save failed, so it must be re-encrypted with the + // original key to stay consistent with the rolled-back (still-encrypted) database row. + _encryption.Received(1).EncryptFileInPlace(filePath, "old-key"); + } + + [Fact] + public async Task UpdateFile_ReplacementUpload_SaveFails_RestoresOriginalFile() + { + var (service, fileGuid, filePath) = await BuildServiceOverFailingSaveAsync(); + _storage.ManagedFileExists(filePath).Returns(true); + const string backupPath = @"C:\FakeTemp\backup-original"; + _storage.BackupManagedFile(filePath).Returns(backupPath); + var request = new CmsFileUpdateRequest { Description = "seeded", LastModifiedOn = _seededModifiedOn }; + + await Assert.ThrowsAsync( + () => service.UpdateFileAsync(fileGuid, request, MakeFormFile("newversion.pdf"), TestContext.Current.CancellationToken)); + + // ReplaceInPlace swapped in the new upload, but the save failed, so the pre-replacement + // backup must be moved back over the managed path to restore the original bytes. + _storage.Received(1).BackupManagedFile(filePath); + _storage.Received(1).ReplaceInPlace(backupPath, filePath); + } + + [Fact] + public async Task UpdateFile_ReplacementUpload_SaveFails_RestoreFails_PreservesBackup() + { + var (service, fileGuid, filePath) = await BuildServiceOverFailingSaveAsync(); + _storage.ManagedFileExists(filePath).Returns(true); + + string backupPath = MakeRealBackupFile(); + try + { + _storage.BackupManagedFile(filePath).Returns(backupPath); + // The restore attempt fails, so the backup holds the only copy of the pre-update bytes + // (the database rolled back to that older record) and must be preserved, not deleted. + _storage.When(s => s.ReplaceInPlace(backupPath, filePath)) + .Do(_ => throw new IOException("restore failed")); + var request = new CmsFileUpdateRequest { Description = "seeded", LastModifiedOn = _seededModifiedOn }; + + await Assert.ThrowsAsync( + () => service.UpdateFileAsync(fileGuid, request, MakeFormFile("newversion.pdf"), TestContext.Current.CancellationToken)); + + _storage.Received(1).ReplaceInPlace(backupPath, filePath); + Assert.True(System.IO.File.Exists(backupPath)); + } + finally + { + if (System.IO.File.Exists(backupPath)) + { + System.IO.File.Delete(backupPath); + } + } + } + + [Fact] + public async Task UpdateFile_ReplacementUpload_SaveSucceeds_DeletesBackup() + { + var file = await SeedFileAsync(); + _storage.ManagedFileExists(file.FilePath).Returns(true); + + string backupPath = MakeRealBackupFile(); + try + { + _storage.BackupManagedFile(file.FilePath).Returns(backupPath); + var request = new CmsFileUpdateRequest { Description = "seeded", LastModifiedOn = _seededModifiedOn }; + + await _service.UpdateFileAsync(file.FileGuid, request, MakeFormFile("newversion.pdf"), TestContext.Current.CancellationToken); + + // A committed save makes the pre-replacement backup obsolete; it must be cleaned up. + Assert.False(System.IO.File.Exists(backupPath)); + } + finally + { + if (System.IO.File.Exists(backupPath)) + { + System.IO.File.Delete(backupPath); + } + } + } + + #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 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"); + } + + [Fact] + public async Task PermanentDelete_StillSucceeds_WhenDiskDeleteFails() + { + var file = await SeedFileAsync(); + _storage.ManagedFileExists(file.FilePath).Returns(true); + _storage.When(s => s.DeleteManagedFile(file.FilePath)).Do(_ => throw new IOException("file is locked")); + + var result = await _service.PermanentlyDeleteFileAsync(file.FileGuid, TestContext.Current.CancellationToken); + + // The record is gone by the time the disk delete runs, so a storage failure is logged + // (orphaned bytes) rather than surfaced as a failure of the whole delete. + Assert.True(result); + Assert.Empty(await _context.Files.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task PurgeDeletedFiles_RemovesOnlyFilesPastRetention() + { + var stale = await SeedFileAsync(f => f.DeletedOn = DateTime.Now.AddDays(-40)); + var recent = await SeedFileAsync(f => f.DeletedOn = DateTime.Now.AddDays(-5)); + var active = await SeedFileAsync(); + + var purged = await _service.PurgeDeletedFilesAsync(30, TestContext.Current.CancellationToken); + + Assert.Equal(1, purged); + var remaining = await _context.Files.Select(f => f.FileGuid).ToListAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain(stale.FileGuid, remaining); + Assert.Contains(recent.FileGuid, remaining); + Assert.Contains(active.FileGuid, remaining); + } + + [Fact] + public async Task PurgeDeletedFiles_IsolatesPerFileFailure_AndContinuesPurgingOthers() + { + // A transient save error (e.g. SqlException) on one file must not abort the whole + // nightly purge run; the loop should isolate it and keep purging the rest. + var storeName = "VIPER_" + Guid.NewGuid(); + var failing = new File + { + FileGuid = Guid.NewGuid(), + FilePath = @"C:\FakeRoot\cats\bad.pdf", + Folder = "cats", + FriendlyName = "cats-bad.pdf", + Description = "seeded", + ModifiedBy = "test", + ModifiedOn = DateTime.Now, + DeletedOn = DateTime.Now.AddDays(-40) + }; + var good = new File + { + FileGuid = Guid.NewGuid(), + FilePath = @"C:\FakeRoot\cats\good.pdf", + Folder = "cats", + FriendlyName = "cats-good.pdf", + Description = "seeded", + ModifiedBy = "test", + ModifiedOn = DateTime.Now, + DeletedOn = DateTime.Now.AddDays(-40) + }; + await using (var seedContext = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(storeName).Options)) + { + seedContext.Files.AddRange(failing, good); + await seedContext.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using var partiallyFailingContext = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(storeName) + .AddInterceptors(new SelectiveThrowingSaveInterceptor(failing.FileGuid)) + .Options); + var service = new CmsFileService(partiallyFailingContext, _aaudContext, _storage, _encryption, _audit, + _userHelper, Substitute.For>()); + + var purged = await service.PurgeDeletedFilesAsync(30, TestContext.Current.CancellationToken); + + Assert.Equal(1, purged); + var remaining = await partiallyFailingContext.Files.Select(f => f.FileGuid) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Contains(failing.FileGuid, remaining); + Assert.DoesNotContain(good.FileGuid, remaining); + } + + [Fact] + public async Task PurgeDeletedFiles_DetachesFromContentBlocks() + { + var file = await SeedFileAsync(f => f.DeletedOn = DateTime.Now.AddDays(-40)); + _context.ContentBlockToFiles.Add(new ContentBlockToFile + { + ContentBlockId = 1, + FileGuid = file.FileGuid + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var purged = await _service.PurgeDeletedFilesAsync(30, TestContext.Current.CancellationToken); + + Assert.Equal(1, purged); + Assert.Empty(await _context.Files.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await _context.ContentBlockToFiles.ToListAsync(TestContext.Current.CancellationToken)); + } + + #endregion + + #region CheckName + + [Fact] + public async Task CheckName_FreeName_ReturnsNotInUse() + { + _storage.FileNameInUse("cats", "report.pdf").Returns(false); + _storage.BuildManagedPath("cats", "report.pdf").Returns(@"C:\FakeRoot\cats\report.pdf"); + + var dto = await _service.CheckNameAsync("cats", "report.pdf", TestContext.Current.CancellationToken); + + Assert.False(dto.InUse); + Assert.Equal("report.pdf", dto.SuggestedName); + Assert.Null(dto.ExistingFileGuid); + } + + [Fact] + public async Task CheckName_DiskConflictOnly_SuggestsRename() + { + _storage.BuildManagedPath("cats", "report.pdf").Returns(@"C:\FakeRoot\cats\report.pdf"); + _storage.FileNameInUse("cats", "report.pdf").Returns(true); + _storage.GetAvailableFileName("cats", "report.pdf").Returns("report_0.pdf"); + + var dto = await _service.CheckNameAsync("cats", "report.pdf", TestContext.Current.CancellationToken); + + Assert.True(dto.InUse); + Assert.Equal("report_0.pdf", dto.SuggestedName); + Assert.Null(dto.ExistingFileGuid); + } + + [Fact] + public async Task CheckName_ManagedRecordConflict_ReturnsExistingFile() + { + const string targetPath = @"C:\FakeRoot\cats\report.pdf"; + _storage.BuildManagedPath("cats", "report.pdf").Returns(targetPath); + _storage.FileNameInUse("cats", "report.pdf").Returns(false); + _storage.GetAvailableFileName("cats", "report.pdf").Returns("report_0.pdf"); + + var seeded = await SeedFileAsync(f => + { + f.FilePath = targetPath; + f.FriendlyName = "cats-report.pdf"; + f.DeletedOn = null; + }); + + var dto = await _service.CheckNameAsync("cats", "report.pdf", TestContext.Current.CancellationToken); + + Assert.True(dto.InUse); + Assert.Equal(seeded.FileGuid, dto.ExistingFileGuid); + Assert.Equal("cats-report.pdf", dto.ExistingFriendlyName); + Assert.False(dto.ExistingDeleted); + } + + [Fact] + public async Task CheckName_ManagedRecordConflict_SetsExistingDeleted_WhenSoftDeleted() + { + const string targetPath = @"C:\FakeRoot\cats\report.pdf"; + _storage.BuildManagedPath("cats", "report.pdf").Returns(targetPath); + _storage.FileNameInUse("cats", "report.pdf").Returns(false); + _storage.GetAvailableFileName("cats", "report.pdf").Returns("report_0.pdf"); + + await SeedFileAsync(f => + { + f.FilePath = targetPath; + f.FriendlyName = "cats-report.pdf"; + f.DeletedOn = DateTime.Now; + }); + + var dto = await _service.CheckNameAsync("cats", "report.pdf", TestContext.Current.CancellationToken); + + Assert.True(dto.InUse); + Assert.True(dto.ExistingDeleted); + } + + [Fact] + public async Task CheckName_InvalidFolder_Throws() + { + _storage.IsValidFolder("nope").Returns(false); + + await Assert.ThrowsAsync( + () => _service.CheckNameAsync("nope", "report.pdf", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CheckName_DisallowedFileType_Throws() + { + await Assert.ThrowsAsync( + () => _service.CheckNameAsync("cats", "evil.bat", TestContext.Current.CancellationToken)); + } + + #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, ct: TestContext.Current.CancellationToken); + var (deleted, deletedTotal) = await _service.GetFilesAsync(null, "deleted", null, null, null, 1, 50, null, false, ct: TestContext.Current.CancellationToken); + var (all, allTotal) = await _service.GetFilesAsync(null, "all", null, null, null, 1, 50, null, false, ct: 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_SortsByDeletedOn_OldestFirstForPurgeViews() + { + await SeedFileAsync(f => + { + f.FriendlyName = "cats-newer.pdf"; + f.FilePath = @"C:\FakeRoot\cats\newer.pdf"; + f.DeletedOn = DateTime.Now.AddDays(-1); + }); + await SeedFileAsync(f => + { + f.FriendlyName = "cats-older.pdf"; + f.FilePath = @"C:\FakeRoot\cats\older.pdf"; + f.DeletedOn = DateTime.Now.AddDays(-20); + }); + + var (ascending, _) = await _service.GetFilesAsync(null, "deleted", null, null, null, 1, 50, + "deletedOn", false, ct: TestContext.Current.CancellationToken); + var (descending, _) = await _service.GetFilesAsync(null, "deleted", null, null, null, 1, 50, + "deletedOn", true, ct: TestContext.Current.CancellationToken); + + Assert.Equal(new[] { "cats-older.pdf", "cats-newer.pdf" }, ascending.Select(f => f.FriendlyName)); + Assert.Equal(new[] { "cats-newer.pdf", "cats-older.pdf" }, descending.Select(f => f.FriendlyName)); + } + + [Fact] + public async Task GetFiles_Deleted_ScopedToOwner_ReturnsOnlyFilesTheyDeleted() + { + var mine = await SeedFileAsync(f => + { + f.FriendlyName = "cats-mine.pdf"; + f.DeletedOn = DateTime.Now; + f.ModifiedBy = "me"; + }); + await SeedFileAsync(f => + { + f.FriendlyName = "cats-theirs.pdf"; + f.DeletedOn = DateTime.Now; + f.ModifiedBy = "them"; + }); + + var (files, total) = await _service.GetFilesAsync(null, "deleted", null, null, null, 1, 50, null, false, + "me", TestContext.Current.CancellationToken); + + Assert.Equal(1, total); + Assert.Equal(mine.FileGuid, Assert.Single(files).FileGuid); + } + + [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, ct: 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, ct: 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, ct: TestContext.Current.CancellationToken); + + Assert.Equal(5, total); + Assert.Equal(2, page2.Count); + Assert.Equal("cats-file2.pdf", page2[0].FriendlyName); + } + + [Fact] + public async Task GetFolderCounts_RollsUpSubfolders_AndFiltersStatusEncryptedAndPublic() + { + await SeedFileAsync(f => f.AllowPublicAccess = true); + 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"; + f.Encrypted = true; + }); + await SeedFileAsync(f => + { + f.Folder = "students"; + f.FriendlyName = "students-deleted.pdf"; + f.FilePath = @"C:\FakeRoot\students\deleted.pdf"; + f.DeletedOn = DateTime.Now; + }); + + var active = await _service.GetFolderCountsAsync("active", null, null, ct: TestContext.Current.CancellationToken); + var unencrypted = await _service.GetFolderCountsAsync("active", false, null, ct: TestContext.Current.CancellationToken); + var publicOnly = await _service.GetFolderCountsAsync("active", null, true, ct: TestContext.Current.CancellationToken); + + Assert.Equal(2, active.Count); + Assert.Equal(2, active.Single(c => c.Folder == "cats").Count); + Assert.Equal(1, active.Single(c => c.Folder == "students").Count); + var onlyCats = Assert.Single(unencrypted); + Assert.Equal("cats", onlyCats.Folder); + Assert.Equal(2, onlyCats.Count); + var publicCats = Assert.Single(publicOnly); + Assert.Equal("cats", publicCats.Folder); + Assert.Equal(1, publicCats.Count); + } + + [Fact] + public async Task GetFolderCounts_MergesFolderCasings() + { + await SeedFileAsync(f => f.Folder = "Accreditation"); + await SeedFileAsync(f => + { + f.Folder = "accreditation"; + f.FriendlyName = "accreditation-two.pdf"; + f.FilePath = @"C:\FakeRoot\accreditation\two.pdf"; + }); + + var counts = await _service.GetFolderCountsAsync("active", null, null, ct: TestContext.Current.CancellationToken); + + var entry = Assert.Single(counts); + Assert.Equal(2, entry.Count); + } + + #endregion +} diff --git a/test/CMS/CmsFileStorageServiceTests.cs b/test/CMS/CmsFileStorageServiceTests.cs new file mode 100644 index 000000000..c4f56f179 --- /dev/null +++ b/test/CMS/CmsFileStorageServiceTests.cs @@ -0,0 +1,323 @@ +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")); + } + + [Fact] + public void FileNameInUse_DetectsRecordStoredUnderAnotherStorageRoot() + { + // Rows created in another environment keep that environment's root in FilePath + // (ReplaceRootFolder rewrites them at read time); the collision check must still + // treat the folder+name as taken, in either separator style. + _context.Files.Add(new Viper.Models.VIPER.File + { + FileGuid = Guid.NewGuid(), + FilePath = @"S:\Files\cats\otherroot.pdf", + Folder = "cats", + FriendlyName = "cats-otherroot.pdf", + Description = "", + ModifiedBy = "test", + ModifiedOn = DateTime.Now + }); + _context.Files.Add(new Viper.Models.VIPER.File + { + FileGuid = Guid.NewGuid(), + FilePath = "/srv/files/cats/fwdroot.pdf", + Folder = "cats", + FriendlyName = "cats-fwdroot.pdf", + Description = "", + ModifiedBy = "test", + ModifiedOn = DateTime.Now + }); + _context.SaveChanges(); + + Assert.True(_service.FileNameInUse("cats", "otherroot.pdf")); + Assert.True(_service.FileNameInUse("cats", "fwdroot.pdf")); + Assert.False(_service.FileNameInUse("cats", "stillfree.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/test/CMS/CmsTrashPurgeScheduledJobTests.cs b/test/CMS/CmsTrashPurgeScheduledJobTests.cs new file mode 100644 index 000000000..ffdab6d37 --- /dev/null +++ b/test/CMS/CmsTrashPurgeScheduledJobTests.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Jobs; +using Viper.Areas.CMS.Services; +using Viper.Areas.Scheduler.Models; + +namespace Viper.test.CMS; + +/// +/// The trash-purge job permanently deletes files, so its config gate is safety-critical: it must +/// stay off unless an environment explicitly opts in via . +/// +public sealed class CmsTrashPurgeScheduledJobTests +{ + private static CmsTrashPurgeScheduledJob MakeJob(ICmsFileService fileService, bool? enabled) + { + var settings = new Dictionary(); + if (enabled != null) + { + settings[CmsTrash.PurgeEnabledConfigKey] = enabled.Value ? "true" : "false"; + } + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + return new CmsTrashPurgeScheduledJob( + fileService, configuration, Substitute.For>()); + } + + private static ScheduledJobContext Context() => new(ScheduledJobContext.SchedulerActor); + + [Fact] + public async Task RunAsync_DoesNotPurge_WhenFlagMissing() + { + var fileService = Substitute.For(); + var job = MakeJob(fileService, enabled: null); + + await job.RunAsync(Context(), CancellationToken.None); + + await fileService.DidNotReceiveWithAnyArgs().PurgeDeletedFilesAsync(default, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task RunAsync_DoesNotPurge_WhenDisabled() + { + var fileService = Substitute.For(); + var job = MakeJob(fileService, enabled: false); + + await job.RunAsync(Context(), CancellationToken.None); + + await fileService.DidNotReceiveWithAnyArgs().PurgeDeletedFilesAsync(default, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task RunAsync_Purges_WhenEnabled() + { + var fileService = Substitute.For(); + fileService.PurgeDeletedFilesAsync(Arg.Any(), Arg.Any()).Returns(3); + var job = MakeJob(fileService, enabled: true); + + await job.RunAsync(Context(), CancellationToken.None); + + await fileService.Received(1).PurgeDeletedFilesAsync(CmsTrash.RetentionDays, Arg.Any()); + } +} diff --git a/test/CMS/CmsUserPhotoServiceTests.cs b/test/CMS/CmsUserPhotoServiceTests.cs new file mode 100644 index 000000000..a352ab603 --- /dev/null +++ b/test/CMS/CmsUserPhotoServiceTests.cs @@ -0,0 +1,214 @@ +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 (MailId, LoginId, IamId, MothraId), +/// alternate ProfilePhotos lookup with traversal protection, delegation to the Students photo +/// pipeline, and the Last-Modified timestamp used for conditional-caching (FIX 4). +/// +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", + string mothraId = "m1") + { + _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 = mothraId, + 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, null, preferAltPhoto: false, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo.Bytes); + await _photoService.Received(1).GetStudentPhotoAsync("jdoe"); + } + + [Fact] + public async Task GetUserPhoto_ByLoginId_ResolvesMailIdThroughAaud() + { + SeedUser(loginId: "jdoe", mailId: "janedoe"); + + await _service.GetUserPhotoAsync(null, "jdoe", null, null, preferAltPhoto: false, + TestContext.Current.CancellationToken); + + await _photoService.Received(1).GetStudentPhotoAsync("janedoe"); + } + + [Fact] + public async Task GetUserPhoto_ByMothraId_ResolvesMailIdThroughAaud() + { + SeedUser(mothraId: "m-9001", mailId: "janedoe"); + + await _service.GetUserPhotoAsync(null, null, null, "m-9001", preferAltPhoto: false, + TestContext.Current.CancellationToken); + + await _photoService.Received(1).GetStudentPhotoAsync("janedoe"); + } + + [Fact] + public async Task GetUserPhoto_UnknownMothraId_FallsBackToDefaultPipeline() + { + var photo = await _service.GetUserPhotoAsync(null, null, null, "no-such-mothra", preferAltPhoto: false, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo.Bytes); + await _photoService.Received(1).GetStudentPhotoAsync(string.Empty); + } + + [Fact] + public async Task GetUserPhoto_UnknownLoginId_FallsBackToDefaultPipeline() + { + var photo = await _service.GetUserPhotoAsync(null, "nosuchuser", null, null, preferAltPhoto: false, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo.Bytes); + 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, null, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(AltPhotoBytes, photo.Bytes); + 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, null, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(AltPhotoBytes, photo.Bytes); + 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, null, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo.Bytes); + 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, null, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo.Bytes); + } + + [Fact] + public async Task GetUserPhoto_AltPhoto_LastModifiedReflectsFileWriteTime_TruncatedToWholeSeconds() + { + SeedUser(iamId: "1000999"); + var photoPath = Path.Join(_profilePhotoRoot, "1000999.jpg"); + await File.WriteAllBytesAsync(photoPath, AltPhotoBytes, TestContext.Current.CancellationToken); + var fileWriteTime = File.GetLastWriteTimeUtc(photoPath); + + var photo = await _service.GetUserPhotoAsync(null, "jdoe", null, null, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(0, photo.LastModified.Millisecond); + Assert.True(Math.Abs((photo.LastModified.UtcDateTime - fileWriteTime).TotalSeconds) < 1.5); + } + + [Fact] + public async Task GetUserPhoto_IdCardPhoto_LastModifiedIsStableAcrossCalls() + { + // The delegated Students pipeline exposes no per-file timestamp, so id-card/nopic + // responses use a stable per-process proxy; repeated calls must agree. + var first = await _service.GetUserPhotoAsync("jdoe", null, null, null, preferAltPhoto: false, + TestContext.Current.CancellationToken); + var second = await _service.GetUserPhotoAsync("jdoe", null, null, null, preferAltPhoto: false, + TestContext.Current.CancellationToken); + + Assert.Equal(first.LastModified, second.LastModified); + Assert.Equal(0, first.LastModified.Millisecond); + } +} diff --git a/test/CMS/MaxLengthEachAttributeTests.cs b/test/CMS/MaxLengthEachAttributeTests.cs new file mode 100644 index 000000000..8359c47fa --- /dev/null +++ b/test/CMS/MaxLengthEachAttributeTests.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; +using Viper.Areas.CMS.Validation; + +namespace Viper.test.CMS; + +/// +/// Tests for MaxLengthEachAttribute, which bounds each string element of a collection (used for +/// left-nav item permission strings vs the varchar(500) permission column). +/// +public sealed class MaxLengthEachAttributeTests +{ + private static ValidationResult? Validate(object? value, int length) + { + var attribute = new MaxLengthEachAttribute(length); + return attribute.GetValidationResult(value, new ValidationContext(new object())); + } + + [Fact] + public void AllElementsWithinLimit_Succeeds() + { + var value = new List { "SVMSecure.CATS", new string('a', 500) }; + + Assert.Equal(ValidationResult.Success, Validate(value, 500)); + } + + [Fact] + public void AnyElementOverLimit_Fails() + { + var value = new List { "ok", new string('a', 501) }; + + Assert.NotEqual(ValidationResult.Success, Validate(value, 500)); + } + + [Fact] + public void NullValue_Succeeds() + { + Assert.Equal(ValidationResult.Success, Validate(null, 500)); + } + + [Fact] + public void EmptyCollection_Succeeds() + { + Assert.Equal(ValidationResult.Success, Validate(new List(), 500)); + } + + [Fact] + public void StringValue_ThrowsMisuseGuard() + { + // A string is IEnumerable; applying the attribute to a string property would + // silently iterate characters and never enforce Length. Misuse must fail loudly. + Assert.Throws(() => Validate(new string('a', 501), 500)); + } +} diff --git a/test/CMS/SafeUrlAttributeTests.cs b/test/CMS/SafeUrlAttributeTests.cs new file mode 100644 index 000000000..c8d52cc7d --- /dev/null +++ b/test/CMS/SafeUrlAttributeTests.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.DataAnnotations; +using Viper.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)); + } + + [Theory] + [InlineData("//evil.example/path")] + [InlineData("//evil.example")] + [InlineData("/\\evil.example/path")] + [InlineData("\\\\evil.example\\path")] + [InlineData("\\/evil.example")] + public void BothModes_RejectProtocolRelativeUrls(string url) + { + // A scheme-less "//host" (or its backslash variants) navigates off-site, so it must be + // rejected even in relative mode, where bare relative paths are otherwise allowed. + Assert.NotEqual(ValidationResult.Success, Validate(url, allowRelative: true)); + Assert.NotEqual(ValidationResult.Success, Validate(url, allowRelative: false)); + } + + [Fact] + public void RelativeMode_AllowsColonAfterFirstSegment() + { + Assert.Equal(ValidationResult.Success, Validate("path/page:1", allowRelative: true)); + } +} diff --git a/test/Classes/Utilities/DateRangeHelperTests.cs b/test/Classes/Utilities/DateRangeHelperTests.cs new file mode 100644 index 000000000..d1bfdb263 --- /dev/null +++ b/test/Classes/Utilities/DateRangeHelperTests.cs @@ -0,0 +1,56 @@ +using Viper.Classes.Utilities; + +namespace Viper.test.Classes.Utilities; + +/// +/// Tests for DateRangeHelper.ExclusiveUpperBound, the shared "To" filter bound used by both +/// the CMS file-audit and content-history date filters. A date-only value is inclusive through +/// the end of that day (the next midnight is returned, to compare with <); any value that +/// carries a time of day, even a single tick past midnight, is returned unchanged. +/// +public class DateRangeHelperTests +{ + [Fact] + public void ExclusiveUpperBound_DateOnly_AdvancesOneDay() + { + var to = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Local); + + var result = DateRangeHelper.ExclusiveUpperBound(to); + + Assert.Equal(new DateTime(2026, 6, 16, 0, 0, 0, DateTimeKind.Local), result); + } + + [Fact] + public void ExclusiveUpperBound_WithTimeComponent_ReturnedUnchanged() + { + // A single tick past midnight already counts as carrying a time of day, so the bound is + // used as-is. This pins the non-obvious "to.Date == to" midnight check in the helper. + var to = new DateTime(2026, 6, 15, 0, 0, 0, DateTimeKind.Local).AddTicks(1); + + var result = DateRangeHelper.ExclusiveUpperBound(to); + + Assert.Equal(to, result); + } + + [Fact] + public void ExclusiveUpperBound_MaxValueDate_ClampsToMaxValueWithoutOverflow() + { + // A user-suppliable "To" at the DateTime ceiling has no next midnight to advance to, so + // advancing by a day would overflow. The helper clamps to MaxValue instead. + var result = DateRangeHelper.ExclusiveUpperBound(DateTime.MaxValue.Date); + + Assert.Equal(DateTime.MaxValue, result); + } + + [Fact] + public void ExclusiveUpperBound_CeilingDayWithTimeComponent_ReturnedUnchanged() + { + // A timestamp ON the ceiling day still carries a time of day, so it passes through + // unchanged rather than being widened to DateTime.MaxValue. + var to = DateTime.MaxValue.Date.AddHours(12); + + var result = DateRangeHelper.ExclusiveUpperBound(to); + + Assert.Equal(to, result); + } +} diff --git a/test/Services/HtmlSanitizerServiceTests.cs b/test/Services/HtmlSanitizerServiceTests.cs index 3b7802fa1..aa94f8293 100644 --- a/test/Services/HtmlSanitizerServiceTests.cs +++ b/test/Services/HtmlSanitizerServiceTests.cs @@ -196,6 +196,16 @@ public void Sanitization_is_idempotent(string input) Assert.Equal(first, second); } + [Theory] + [InlineData("added")] + [InlineData("removedadded")] + public void SanitizeDiff_is_idempotent(string input) + { + var first = _sanitizer.SanitizeDiff(input); + var second = _sanitizer.SanitizeDiff(first); + Assert.Equal(first, second); + } + // === Real-world snapshots from production ContentBlock data === // If these break, several admin pages will visibly regress — review before relaxing. @@ -220,4 +230,51 @@ public void Preserves_realworld_inline_styles(string input, params string[] must Assert.Contains(token, output, StringComparison.OrdinalIgnoreCase); } } + + // === SanitizeDiff — the diff-specific variant adds / to the allowlist, but its + // output is rendered with v-html on the content-block diff pages, so it must keep the same + // XSS guarantees as the base sanitizer. These lock that down. === + + [Theory] + [InlineData("added")] + [InlineData("\"x\"")] + [InlineData("x")] + public void SanitizeDiff_strips_xss_payloads_inside_change_markers(string input) + { + var output = _sanitizer.SanitizeDiff(input); + Assert.DoesNotContain("\"x\""; + var output = _sanitizer.SanitizeDiff(input); + Assert.DoesNotContain("data:", output); + } + + [Theory] + [InlineData("added", "removed", "changed", "added", "removed", "/; only the diff variant does. + // This proves the two policies genuinely differ rather than sharing one allowlist. + var output = _sanitizer.Sanitize(input); + Assert.DoesNotContain(tagFragment, output, StringComparison.OrdinalIgnoreCase); + } } diff --git a/web/Areas/CMS/Constants/CmsFileNaming.cs b/web/Areas/CMS/Constants/CmsFileNaming.cs new file mode 100644 index 000000000..ce72541a5 --- /dev/null +++ b/web/Areas/CMS/Constants/CmsFileNaming.cs @@ -0,0 +1,36 @@ +namespace Viper.Areas.CMS.Constants +{ + /// + /// Shared CMS file naming rules. Mirrors the legacy ColdFusion Files.cfc: + /// friendly names are the folder path with separators dashed, plus the file name. + /// + public static class CmsFileNaming + { + public static string BuildFriendlyName(string folder, string fileName) + { + return folder.Replace('\\', '-').Replace('/', '-') + "-" + fileName; + } + + /// + /// Canonical stored form of a folder path: segments joined with '\' (the legacy + /// convention). Folder input accepts either separator style, so records must be + /// written canonically or the list filter's prefix match would miss them. + /// + public static string NormalizeFolderKey(string folder) + { + return string.Join('\\', folder.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries)); + } + + /// + /// Final path segment of a name, treating both '\' and '/' as separators so path + /// components are stripped identically on every OS (Path.GetFileName only honors the + /// host separator, so on Linux it would keep a "sub\file" prefix from a Windows-style + /// client value). + /// + public static string GetLeafName(string name) + { + int lastSeparator = name.LastIndexOfAny(['\\', '/']); + return lastSeparator >= 0 ? name[(lastSeparator + 1)..] : name; + } + } +} diff --git a/web/Areas/CMS/Constants/CmsFileTypes.cs b/web/Areas/CMS/Constants/CmsFileTypes.cs new file mode 100644 index 000000000..4f2bf6313 --- /dev/null +++ b/web/Areas/CMS/Constants/CmsFileTypes.cs @@ -0,0 +1,73 @@ +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" + }; + + /// + /// Extensions the browser would render as active markup (script execution / inline SVG) + /// rather than download. Legacy .html files must stay downloadable, so instead of removing + /// them from the allow-list we serve these as an attachment, never inline, to close the + /// stored-XSS path from an uploaded .html/.svg running in the app origin. + /// + public static readonly IReadOnlySet InlineUnsafeExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "html", "htm", "xhtml", "svg" + }; + + public static bool IsAllowedFileName(string fileName) + { + return MimeTypes.ContainsKey(GetExtension(fileName)); + } + + /// True when a file must be served as a download rather than rendered inline. + public static bool ShouldForceAttachment(string fileName) + { + return InlineUnsafeExtensions.Contains(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..6c65899cd --- /dev/null +++ b/web/Areas/CMS/Constants/CmsPermissions.cs @@ -0,0 +1,20 @@ +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"; + + // Legacy parity: the ColdFusion CMS gated permanent ("dev only") content-block delete on + // SVMSecure.CATS.Admin (cms/views/contentBlock.cfm, cms/inc_contentBlock.cfm). VIPER 2 reuses + // it for permanent delete of blocks and files; legacy file permanent delete was AllFiles-only, + // so hard delete of files is deliberately narrowed to admins here. + public const string Admin = "SVMSecure.CATS.Admin"; + } +} diff --git a/web/Areas/CMS/Constants/CmsTrash.cs b/web/Areas/CMS/Constants/CmsTrash.cs new file mode 100644 index 000000000..2bad43fa2 --- /dev/null +++ b/web/Areas/CMS/Constants/CmsTrash.cs @@ -0,0 +1,20 @@ +namespace Viper.Areas.CMS.Constants +{ + /// + /// Trash (soft-delete) retention policy. A soft-deleted CMS file stays recoverable for this + /// many days, then the CmsTrashPurgeScheduledJob permanently deletes it (record + disk), + /// matching the legacy 30-day "will be permanently deleted on ..." behavior. + /// + public static class CmsTrash + { + public const int RetentionDays = 30; + + /// + /// Config key gating the trash-purge job. Absent/false means the job never permanently + /// deletes, so it can ship disabled and stay off across app restarts and IIS recycles + /// (unlike a dashboard tweak). Kept false until the legacy VIPER 1 30-day purge is retired, + /// so the two never purge in parallel; flipped to true (via SSM in deployed envs) at cutover. + /// + public const string PurgeEnabledConfigKey = "Cms:TrashPurgeEnabled"; + } +} 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/Controllers/CMSFilesController.cs b/web/Areas/CMS/Controllers/CMSFilesController.cs new file mode 100644 index 000000000..6cd423e1c --- /dev/null +++ b/web/Areas/CMS/Controllers/CMSFilesController.cs @@ -0,0 +1,343 @@ +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; + + // Import/preview/bulk-encrypt process items sequentially (file + DB work per item), so an + // oversized batch risks blowing through the request timeout. + private const int MaxBatchItems = 500; + + 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, ICmsFileImportService importService, RAPSContext rapsContext, + ILogger logger, IUserHelper userHelper) + { + _fileService = fileService; + _storage = storage; + _auditService = auditService; + _importService = importService; + _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, OwnerRestriction(), ct); + if (pagination != null) + { + pagination.TotalRecords = total; + } + return files; + } + + // GET /api/cms/files/folders + // includeData=true unions in folders that exist only on file records (for + // filtering); the default disk-backed list is the upload destination allow-list. + [HttpGet("folders")] + public async Task>> GetFolders(bool includeData = false, CancellationToken ct = default) + { + return includeData ? await _storage.GetFilterFoldersAsync(ct) : _storage.GetTopLevelFolders(); + } + + // GET /api/cms/files/folder-counts + // File counts per top-level folder for filter dropdowns; same status/encrypted/isPublic + // semantics as the list endpoint. + [HttpGet("folder-counts")] + public async Task>> GetFolderCounts( + bool? encrypted, bool? isPublic, string status = "active", CancellationToken ct = default) + { + return await _fileService.GetFolderCountsAsync(status, encrypted, isPublic, OwnerRestriction(), ct); + } + + // 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; + } + + // GET /api/cms/files/check-name — pre-upload conflict check for a folder + file name + [HttpGet("check-name")] + public async Task> CheckName(string folder, string fileName, + CancellationToken ct = default) + { + try + { + return await _fileService.CheckNameAsync(folder, fileName, ct); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + } + + // 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); + } + // Before InvalidOperationException: CmsConcurrencyException derives from it, and a + // stale edit must surface as 409 (someone saved first), not a generic 400. + catch (CmsConcurrencyException ex) + { + return Conflict(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 (!IsCmsAdmin()) + { + 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) + { + if (!IsCmsAdmin()) + { + // Non-admins may only restore files they trashed themselves. + var file = await _fileService.GetFileAsync(fileGuid, ct); + var login = _userHelper.GetCurrentUser()?.LoginId; + if (file?.DeletedOn == null || login == null + || !string.Equals(file.ModifiedBy, login, StringComparison.OrdinalIgnoreCase)) + { + return ForbidApi(); + } + } + return await _fileService.RestoreFileAsync(fileGuid, ct) ? NoContent() : NotFound(); + } + + private bool IsCmsAdmin() => + _userHelper.HasPermission(_rapsContext, _userHelper.GetCurrentUser(), CmsPermissions.Admin); + + // Scopes trash queries: admins see the whole trash (null); everyone else only the files they + // deleted (matched by ModifiedBy), so nothing leaks across users but people can recover their own. + // A missing user context fails closed (empty string matches no ModifiedBy) rather than falling + // through to the admin-level unrestricted view. + private string? OwnerRestriction() + { + var user = _userHelper.GetCurrentUser(); + if (user == null) + { + return string.Empty; + } + return IsCmsAdmin() ? null : user.LoginId; + } + + // 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."); + } + if (request.FilePaths.Count > MaxBatchItems) + { + return BadRequest($"A single request may include at most {MaxBatchItems} items."); + } + + 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/import/preview — dry-run validation of an import request + [HttpPost("import/preview")] + public async Task>> PreviewImport(CmsFileImportRequest request, + CancellationToken ct = default) + { + if (request.FilePaths.Count == 0) + { + return BadRequest("At least one file path is required."); + } + if (request.FilePaths.Count > MaxBatchItems) + { + return BadRequest($"A single request may include at most {MaxBatchItems} items."); + } + + try + { + return await _importService.PreviewImportAsync(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."); + } + if (fileGuids.Count > MaxBatchItems) + { + return BadRequest($"A single request may include at most {MaxBatchItems} items."); + } + 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}", + 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/Controllers/CMSUserPhotoController.cs b/web/Areas/CMS/Controllers/CMSUserPhotoController.cs new file mode 100644 index 000000000..1a541b614 --- /dev/null +++ b/web/Areas/CMS/Controllers/CMSUserPhotoController.cs @@ -0,0 +1,76 @@ +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 const int StaleWhileRevalidateSeconds = 86400; + + 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, mothraId: 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, mothraId: 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, mothraId: null, altPhoto, ct); + } + + // GET /api/cms/photos/by-mothra/{mothraId}?altPhoto=true|false + [HttpGet("by-mothra/{mothraId}")] + public async Task GetByMothraId(string mothraId, bool altPhoto = false, CancellationToken ct = default) + { + return await ServePhoto(mailId: null, loginId: null, iamId: null, mothraId: mothraId, altPhoto, ct); + } + + private async Task ServePhoto(string? mailId, string? loginId, string? iamId, + string? mothraId, bool altPhoto, CancellationToken ct) + { + var photo = await _photoService.GetUserPhotoAsync(mailId, loginId, iamId, mothraId, altPhoto, ct); + + // Photos change rarely; let browsers cache for an hour and keep serving a stale copy + // for up to a day while revalidating (legacy used short-lived cache headers with long + // stale-while-revalidate). Last-Modified/If-Modified-Since lets a 304 skip the body + // entirely, which matters because pages render hundreds of these. + Response.Headers.CacheControl = $"private, max-age={CacheSeconds}, stale-while-revalidate={StaleWhileRevalidateSeconds}"; + Response.GetTypedHeaders().LastModified = photo.LastModified; + + var ifModifiedSince = Request.GetTypedHeaders().IfModifiedSince; + if (ifModifiedSince != null && photo.LastModified <= ifModifiedSince.Value) + { + return StatusCode(StatusCodes.Status304NotModified); + } + + return File(photo.Bytes, "image/jpeg"); + } + } +} diff --git a/web/Areas/CMS/Data/CMS.cs b/web/Areas/CMS/Data/CMS.cs index 1cf19d991..b79802503 100644 --- a/web/Areas/CMS/Data/CMS.cs +++ b/web/Areas/CMS/Data/CMS.cs @@ -1,8 +1,6 @@ using System.IO.Compression; using System.Net; -using System.Security.Cryptography; using System.Text.Json; -using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Net.Http.Headers; @@ -27,35 +25,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 @@ -86,6 +57,20 @@ public CMS(VIPERContext viperContext, RAPSContext rapsContext, IHtmlSanitizerSer if (blocks != null && _rapsContext != null) { + // Resolve the user's access once, not per (block x permission) - mirrors + // CheckFilePermission's single-resolve HashSet approach. + var isCmsAdmin = false; + var hasSvmSecure = false; + HashSet userPermissions = new(StringComparer.OrdinalIgnoreCase); + if (currentUser != null) + { + isCmsAdmin = UserHelper.HasPermission(_rapsContext, currentUser, Constants.CmsPermissions.ManageContentBlocks); + hasSvmSecure = UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure"); + userPermissions = UserHelper.GetAllPermissions(_rapsContext, currentUser) + .Select(p => p.Permission) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + foreach (var b in blocks) { var hasAccess = b.AllowPublicAccess; //block is available without authentication @@ -93,13 +78,12 @@ public CMS(VIPERContext viperContext, RAPSContext rapsContext, IHtmlSanitizerSer { hasAccess = //CMS admin - UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure.CMS.ManageContentBlocks") || + isCmsAdmin || //available to all logged in users - b.ContentBlockToPermissions.Count == 0 && UserHelper.HasPermission(_rapsContext, currentUser, "SVMSecure") || + b.ContentBlockToPermissions.Count == 0 && hasSvmSecure || //available due to having specific permission(s) b.ContentBlockToPermissions.Count > 0 && b.ContentBlockToPermissions - .Any(cp => UserHelper.GetAllPermissions(_rapsContext, currentUser) - .Any(p => string.Compare(cp.Permission, p.Permission, true) == 0)); + .Any(cp => userPermissions.Contains(cp.Permission)); } // only include blocks that the user has permission to see if (hasAccess) @@ -204,6 +188,7 @@ private void SanitizeContentBlocks(IEnumerable blocks) public CMSFile? GetFileByGuid(Guid fileGuid) { var file = _viperContext?.Files + .AsNoTracking() .Include(p => p.FileToPermissions) .Include(p => p.FileToPeople) .AsSplitQuery() @@ -216,6 +201,7 @@ private void SanitizeContentBlocks(IEnumerable blocks) public CMSFile? GetFileByOldUrl(string oldUrl) { var file = _viperContext?.Files + .AsNoTracking() .Include(p => p.FileToPermissions) .Include(p => p.FileToPeople) .AsSplitQuery() @@ -228,6 +214,7 @@ private void SanitizeContentBlocks(IEnumerable blocks) public CMSFile? GetFileByFriendlyName(string friendlyName) { var file = _viperContext?.Files + .AsNoTracking() .Include(p => p.FileToPermissions) .Include(p => p.FileToPeople) .AsSplitQuery() @@ -241,6 +228,7 @@ private void SanitizeContentBlocks(IEnumerable blocks) { var filePath = folder + @"\" + name; var file = _viperContext?.Files + .AsNoTracking() .Include(p => p.FileToPermissions) .Include(p => p.FileToPeople) .AsSplitQuery() @@ -263,73 +251,37 @@ private void SanitizeContentBlocks(IEnumerable blocks) } #endregion - #region public IEnumerable GetAllFiles(string? folder, bool? isPublic, string? search, string? status, bool? encrypted) + #region public static string GetFriendlyURL(string friendlyName) /// - /// Search for matching files + /// Get Friendly URL for a friendly name. Points to the VIPER 2 download handler + /// (CMSController.Files), which checks permissions internally and serves public + /// files anonymously, so no separate /public URL shape is needed. /// - public IEnumerable GetAllFiles(string? folder, bool? isPublic, string? search, string? status, bool? encrypted) - { - - // get files based on paramenters - var files = _viperContext?.Files - .Include(p => p.FileToPermissions) - .Include(p => p.FileToPeople) - .Where(f => (string.IsNullOrEmpty(folder) && f.FilePath.Contains(folder + @"\")) || string.IsNullOrEmpty(folder)) - .Where(f => f.AllowPublicAccess.Equals(isPublic) || isPublic == null) - .Where(c => (string.IsNullOrEmpty(status) || (c.DeletedOn == null && status.ToLower() != "active") || (c.DeletedOn != null && status.ToLower() == "active"))) - .Where(f => f.Encrypted.Equals(encrypted) || encrypted == null) - .Where(f => (string.IsNullOrEmpty(search) || f.FriendlyName.Contains(search, StringComparison.OrdinalIgnoreCase) || - f.Description.Contains(search, StringComparison.OrdinalIgnoreCase) || - string.IsNullOrEmpty(f.OldUrl) ? string.IsNullOrEmpty(search) : f.OldUrl.Contains(search, StringComparison.OrdinalIgnoreCase) || - f.FilePath.Contains(search, StringComparison.OrdinalIgnoreCase))) - .OrderBy(c => c.FriendlyName) - .AsSplitQuery() - .ToList(); - - if (files != null) - { - List cmslist = new(); - foreach (var f in files) - { - CMSFile cmsf = new(f); - cmslist.Add(cmsf); - ReplaceRootFolder(cmsf); - } - - return cmslist; - } - - return new List(); - - } - #endregion - - #region public static string GetFriendlyURL(string friendlyName, bool allowPublicAccess = false) - /// - /// Get Friendly URL for a friendly name. Currently, always points to ColdFusion Viper - /// - public static string GetFriendlyURL(string friendlyName, bool allowPublicAccess = false) + public static string GetFriendlyURL(string friendlyName) { string rootURL = String.Empty; + string pathBase = String.Empty; HttpRequest? thisRequest = HttpHelper.HttpContext?.Request; if (thisRequest != null) { - Uri url = new(thisRequest.GetDisplayUrl()); - rootURL = url.Scheme + Uri.SchemeDelimiter + url.Host; + // Request.Host keeps any non-default port (url.Host would drop it) + rootURL = thisRequest.Scheme + Uri.SchemeDelimiter + thisRequest.Host; + pathBase = thisRequest.PathBase; } - return rootURL + (allowPublicAccess ? @"/public" : "") + @"/cms/files/?fn=" + WebUtility.UrlEncode(friendlyName); + return rootURL + pathBase + @"/CMS/Files?fn=" + WebUtility.UrlEncode(friendlyName); } #endregion - #region public static string GetURL(string fileGUID, bool allowPublicAccess = false) + #region public static string GetURL(string fileGUID) /// /// Get url for a fileGUID /// - public static string GetURL(string fileGUID, bool allowPublicAccess = false) + public static string GetURL(string fileGUID) { - return (allowPublicAccess ? @"/public" : "") + @"/cms/files/?id=" + fileGUID; + string pathBase = HttpHelper.HttpContext?.Request.PathBase ?? String.Empty; + return pathBase + @"/CMS/Files?id=" + fileGUID; } #endregion @@ -448,15 +400,27 @@ public IActionResult DownloadZip(Controller controller, string[] fileGUIDs, stri CMSFile? file = GetFile(guid, null, null, null, null); // only add files that exist and where the user has permission - if (file != null && System.IO.File.Exists(file.FilePath) && CheckFilePermission(file)) + if (file == null || !System.IO.File.Exists(file.FilePath)) { - if (currentUser != null && _viperContext != null) + continue; + } + + if (!CheckFilePermission(file)) + { + // Legacy parity: a denied file in a ZIP request is audited like a single-file deny. + if (_viperContext != null) { - AuditFileAccess(_viperContext, file, currentUser, "AccessFile", string.Empty); + AuditFileAccess(_viperContext, file, currentUser, "AccessFileDenied", string.Empty); } + continue; + } - files.Add(file); + if (_viperContext != null) + { + AuditFileAccess(_viperContext, file, currentUser, "AccessFile", string.Empty); } + + files.Add(file); } if (files.Count == 0) @@ -496,6 +460,9 @@ public IActionResult DownloadZip(Controller controller, string[] fileGUIDs, stri } byte[] bytes = System.IO.File.ReadAllBytes(tempFileName); + // The zip itself is inert, but keep the whole /CMS/Files surface consistent: + // no MIME sniffing on any download response. + CmsFileResponse.SetNoSniff(controller.Response); return controller.File(bytes, MimeTypes["zip"], safeDownloadName); } finally @@ -548,7 +515,7 @@ public IActionResult ProvideFile(Controller controller, string id, string friend if (!CheckFilePermission(file)) { - if (currentUser != null && _viperContext != null) + if (_viperContext != null) { AuditFileAccess(_viperContext, file, currentUser, "AccessFileDenied", detail); } @@ -557,7 +524,7 @@ public IActionResult ProvideFile(Controller controller, string id, string friend return controller.View("~/Views/Home/403.cshtml", (HttpStatusCode)403); } - if (currentUser != null && _viperContext != null) + if (_viperContext != null) { AuditFileAccess(_viperContext, file, currentUser, "AccessFile", detail); } @@ -577,11 +544,15 @@ public IActionResult ProvideFile(Controller controller, string id, string friend contentType = "application/octet-stream"; } + // Stop the browser from MIME-sniffing a benign type into an active one. + CmsFileResponse.SetNoSniff(controller.Response); + // Build Content-Disposition from the stored record's name, not the user-supplied // friendlyName param, and let the framework encode it so a hostile fn cannot shape - // the header. Mirrors the DownloadZip download-name hardening. + // the header. Mirrors the DownloadZip download-name hardening. Inline-unsafe types + // (html/svg) are forced to download so an uploaded page cannot run in the app origin. var downloadName = CmsFilePathSafety.SanitizeZipEntryName(file.FriendlyName, file.FilePath); - var contentDisposition = new ContentDispositionHeaderValue("inline"); + var contentDisposition = new ContentDispositionHeaderValue(CmsFileResponse.DispositionType(file.FilePath)); contentDisposition.SetHttpFileName(downloadName); controller.Response.Headers.ContentDisposition = contentDisposition.ToString(); @@ -637,20 +608,33 @@ private void LogSuspiciousDownloadName(Controller controller, string rawFileName LogSanitizer.SanitizeString(request.HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty)); } - #region public static void AuditFileAccess(VIPERContext viperContext, CMSFile file, AaudUser user, string action, string detail) - public static void AuditFileAccess(VIPERContext viperContext, CMSFile file, AaudUser user, string action, string detail) + #region public static void AuditFileAccess(VIPERContext viperContext, CMSFile file, AaudUser? user, string action, string detail) + public static void AuditFileAccess(VIPERContext viperContext, CMSFile file, AaudUser? user, string action, string detail) { + // Legacy parity: anonymous/public access is audited too (null user -> null login/iam), + // while requests from the internal kiosk range (192.168.*) are not audited at all. + // Dual-mode sockets surface IPv4 as ::ffff:192.168.x.x, so unmap before the check. + var remoteIp = HttpHelper.HttpContext?.Connection.RemoteIpAddress; + if (remoteIp != null) + { + var normalizedIp = remoteIp.IsIPv4MappedToIPv6 ? remoteIp.MapToIPv4() : remoteIp; + if (normalizedIp.ToString().StartsWith("192.168.", StringComparison.Ordinal)) + { + return; + } + } + UserHelper userHelper = new(); FileAudit fileAudit = new() { Timestamp = DateTime.Now, - Loginid = user.LoginId, + Loginid = user?.LoginId, Action = action, Detail = detail, FileGuid = file.FileGuid, FilePath = file.FilePath, - IamId = user.IamId, + IamId = user?.IamId, FileMetaData = JsonSerializer.Serialize(file.MetaData), ClientData = JsonSerializer.Serialize(userHelper.GetClientData()) }; @@ -662,83 +646,25 @@ public static void AuditFileAccess(VIPERContext viperContext, CMSFile file, Aaud #endregion #region public byte[] DecryptFile(byte[] encryptedData, string keystring) + // Cached per CMS instance (per request): DownloadZip decrypts once per encrypted file + // in the archive loop, and each call would otherwise re-read the key file from disk. + private string? _masterKey; + 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; - + _masterKey ??= CmsFileCrypto.ReadMasterKey(CmsFileCrypto.GetConfiguredKeyFilePath()); + 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 @@ -760,11 +686,9 @@ public interface ICMS CMSFile? GetFileByFolderAndName(string folder, string name); - IEnumerable GetAllFiles(string? folder, bool? isPublic, string? search, string? status, bool? encrypted); - - static string GetFriendlyURL(string friendlyName, bool allowPublicAccess = false) => throw new NotImplementedException(); + static string GetFriendlyURL(string friendlyName) => throw new NotImplementedException(); - static string GetURL(string fileGUID, bool allowPublicAccess = false) => throw new NotImplementedException(); + static string GetURL(string fileGUID) => throw new NotImplementedException(); static string GetRootFileFolder() => throw new NotImplementedException(); @@ -778,7 +702,7 @@ public interface ICMS IActionResult ProvideFile(Controller controller, string id, string friendlyName, string oldURL); - static void AuditFileAccess(VIPERContext viperContext, CMSFile file, AaudUser user, string action, string detail) => throw new NotImplementedException(); + static void AuditFileAccess(VIPERContext viperContext, CMSFile file, AaudUser? user, string action, string detail) => throw new NotImplementedException(); byte[] DecryptFile(byte[] encryptedData, string keystring); diff --git a/web/Areas/CMS/Jobs/CmsTrashPurgeScheduledJob.cs b/web/Areas/CMS/Jobs/CmsTrashPurgeScheduledJob.cs new file mode 100644 index 000000000..64fc4084c --- /dev/null +++ b/web/Areas/CMS/Jobs/CmsTrashPurgeScheduledJob.cs @@ -0,0 +1,51 @@ +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Services; +using Viper.Areas.Scheduler.Models; +using Viper.Areas.Scheduler.Services; + +namespace Viper.Areas.CMS.Jobs +{ + /// + /// Daily purge of the CMS file trash: permanently deletes files that were soft-deleted more than + /// days ago (record + disk), restoring the legacy 30-day + /// retention routine that VIPER 1 ran but the migration hadn't yet reimplemented. + /// + /// Gated by (default false): the job stays + /// registered and fires on cron, but no-ops until an environment opts in via config. That lets + /// it ship disabled and be enabled only once the legacy VIPER 1 purge is retired, without a + /// dashboard change that the next app restart would silently undo. + /// + [ScheduledJob(id: "cms:trash-purge", cron: "0 3 * * *")] + public sealed class CmsTrashPurgeScheduledJob : IScheduledJob + { + private readonly ICmsFileService _fileService; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public CmsTrashPurgeScheduledJob(ICmsFileService fileService, IConfiguration configuration, + ILogger logger) + { + _fileService = fileService; + _configuration = configuration; + _logger = logger; + } + + public async Task RunAsync(ScheduledJobContext context, CancellationToken ct) + { + if (!_configuration.GetValue(CmsTrash.PurgeEnabledConfigKey)) + { + context.WriteLine( + $"CMS trash purge is disabled ({CmsTrash.PurgeEnabledConfigKey} is not true); skipping."); + _logger.LogInformation( + "CMS trash purge skipped: {ConfigKey} is not enabled", CmsTrash.PurgeEnabledConfigKey); + return; + } + + context.WriteLine($"CMS trash purge starting (retention={CmsTrash.RetentionDays}d, modBy={context.ModBy})"); + var purged = await _fileService.PurgeDeletedFilesAsync(CmsTrash.RetentionDays, ct); + context.WriteLine($"Purged {purged} trashed file(s) older than {CmsTrash.RetentionDays} days."); + _logger.LogInformation( + "CMS trash purge removed {Count} file(s) older than {Days} days", purged, CmsTrash.RetentionDays); + } + } +} diff --git a/web/Areas/CMS/Models/CMSFile.cs b/web/Areas/CMS/Models/CMSFile.cs index 2a2fa482a..9cad5266d 100644 --- a/web/Areas/CMS/Models/CMSFile.cs +++ b/web/Areas/CMS/Models/CMSFile.cs @@ -12,8 +12,8 @@ public sealed class CMSFile : File public CMSFile() { - FriendlyURL = Data.CMS.GetFriendlyURL(FriendlyName, AllowPublicAccess); - URL = Data.CMS.GetURL(FileGuid.ToString(), AllowPublicAccess); + FriendlyURL = Data.CMS.GetFriendlyURL(FriendlyName); + URL = Data.CMS.GetURL(FileGuid.ToString()); MetaData = new CMSFileMetaData { Folder = this.Folder, @@ -41,8 +41,8 @@ public CMSFile(File file) this.ModifiedOn = file.ModifiedOn; this.OldUrl = file.OldUrl; - FriendlyURL = Data.CMS.GetFriendlyURL(FriendlyName, AllowPublicAccess); - URL = Data.CMS.GetURL(FileGuid.ToString(), AllowPublicAccess); + FriendlyURL = Data.CMS.GetFriendlyURL(FriendlyName); + URL = Data.CMS.GetURL(FileGuid.ToString()); MetaData = new CMSFileMetaData { Folder = this.Folder, diff --git a/web/Areas/CMS/Models/CmsFileMapper.cs b/web/Areas/CMS/Models/CmsFileMapper.cs new file mode 100644 index 000000000..7892d4946 --- /dev/null +++ b/web/Areas/CMS/Models/CmsFileMapper.cs @@ -0,0 +1,44 @@ +using Riok.Mapperly.Abstractions; +using Viper.Areas.CMS.Constants; +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); + // Both-separator leaf: FilePath can carry another environment's separators. + dto.FileName = CmsFileNaming.GetLeafName(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()); + dto.FriendlyUrl = Data.CMS.GetFriendlyURL(file.FriendlyName); + // When a file is in the trash, surface the date the purge job will permanently delete it. + dto.PurgeOn = file.DeletedOn?.AddDays(CmsTrash.RetentionDays); + return dto; + } + + [MapperIgnoreTarget(nameof(CmsFileDto.FileName))] + [MapperIgnoreTarget(nameof(CmsFileDto.Permissions))] + [MapperIgnoreTarget(nameof(CmsFileDto.People))] + [MapperIgnoreTarget(nameof(CmsFileDto.Url))] + [MapperIgnoreTarget(nameof(CmsFileDto.FriendlyUrl))] + [MapperIgnoreTarget(nameof(CmsFileDto.PurgeOn))] + 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..e56fc4cbf --- /dev/null +++ b/web/Areas/CMS/Models/DTOs/CmsFileDto.cs @@ -0,0 +1,60 @@ +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; } + + /// When a trashed file will be permanently purged (DeletedOn + retention); null if not deleted. + public DateTime? PurgeOn { 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; } + } + + public class CmsFolderCountDto + { + public string Folder { get; set; } = string.Empty; + public int Count { get; set; } + } + + /// Result of a pre-upload name availability check for a folder. + public class CmsFileNameCheckDto + { + public bool InUse { get; set; } + + /// The name an auto-rename would store the file under (the name itself when free). + public string SuggestedName { get; set; } = string.Empty; + + /// Set when the conflicting name belongs to an existing file record. + public Guid? ExistingFileGuid { get; set; } + + public string? ExistingFriendlyName { get; set; } + + public bool ExistingDeleted { get; set; } + + /// + /// ModifiedOn of the conflicting record, echoed back as LastModifiedOn when the user + /// chooses to overwrite it, so the overwrite hits the same 409 stale-edit guard as a + /// metadata edit if the record changed after this check. + /// + public DateTime? ExistingModifiedOn { get; set; } + } +} diff --git a/web/Areas/CMS/Models/DTOs/CmsFileImportDtos.cs b/web/Areas/CMS/Models/DTOs/CmsFileImportDtos.cs new file mode 100644 index 000000000..ac51338aa --- /dev/null +++ b/web/Areas/CMS/Models/DTOs/CmsFileImportDtos.cs @@ -0,0 +1,64 @@ +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; } + } + + /// Dry-run result for one path of an import request; nothing is moved or saved. + public class CmsFileImportPreviewResult + { + public string FilePath { get; set; } = string.Empty; + + public bool CanImport { get; set; } + + /// Blocking issue (CanImport false) or informational note (CanImport true). + public string? Message { get; set; } + + /// Name the file will be stored under, after any auto-rename. + public string? FileName { get; set; } + + /// Original name when an auto-rename will occur. + public string? RenamedFrom { get; set; } + + public string? FriendlyName { get; set; } + + public string? OldUrl { 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/Models/DTOs/CmsFileRequests.cs b/web/Areas/CMS/Models/DTOs/CmsFileRequests.cs new file mode 100644 index 000000000..73dfafa4d --- /dev/null +++ b/web/Areas/CMS/Models/DTOs/CmsFileRequests.cs @@ -0,0 +1,63 @@ +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; } + + /// Optional stored-name override; defaults to the uploaded file's name. + [MaxLength(256)] + public string? FileName { get; set; } + + /// + /// When true, overwrite a same-named disk file that has no file record. A conflict with + /// a managed record still fails (replace it by editing that record instead). + /// + public bool? Overwrite { get; set; } + } + + /// + /// Form fields for editing file metadata; the file itself is an optional replacement upload. + /// + public class CmsFileUpdateRequest + { + /// + /// ModifiedOn value the client loaded; updates with a stale value get 409 Conflict + /// and a missing value gets 400 (mirrors CMSBlockAddEdit.LastModifiedOn). + /// + public DateTime? LastModifiedOn { get; set; } + + [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/Models/DTOs/CreateLinkDto.cs b/web/Areas/CMS/Models/DTOs/CreateLinkDto.cs index 7c3091130..719cf5c69 100644 --- a/web/Areas/CMS/Models/DTOs/CreateLinkDto.cs +++ b/web/Areas/CMS/Models/DTOs/CreateLinkDto.cs @@ -1,5 +1,5 @@ using System.ComponentModel.DataAnnotations; -using Areas.CMS.Validation; +using Viper.Areas.CMS.Validation; namespace Areas.CMS.Models.DTOs; diff --git a/web/Areas/CMS/Services/CmsConcurrencyException.cs b/web/Areas/CMS/Services/CmsConcurrencyException.cs new file mode 100644 index 000000000..f35d2bb54 --- /dev/null +++ b/web/Areas/CMS/Services/CmsConcurrencyException.cs @@ -0,0 +1,12 @@ +namespace Viper.Areas.CMS.Services +{ + /// + /// Thrown when an update is based on a stale copy of a record (someone else saved since + /// the editor loaded it). Controllers translate this to 409 Conflict. Shared by the + /// content-block and file stale-edit guards. + /// + public class CmsConcurrencyException : InvalidOperationException + { + public CmsConcurrencyException(string message) : base(message) { } + } +} diff --git a/web/Areas/CMS/Services/CmsDownloadRateLimiting.cs b/web/Areas/CMS/Services/CmsDownloadRateLimiting.cs new file mode 100644 index 000000000..003360ed8 --- /dev/null +++ b/web/Areas/CMS/Services/CmsDownloadRateLimiting.cs @@ -0,0 +1,119 @@ +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. Sanitize the partition key + // too: for anonymous requests it falls back to the proxy-resolved client IP, + // which can derive from a user-controllable forwarded header. + httpContext.RequestServices + .GetRequiredService() + .CreateLogger(nameof(CmsDownloadRateLimiting)) + .LogInformation("Download rate limit hit for {PartitionKey} on {Path}", + LogSanitizer.SanitizeString(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/Areas/CMS/Services/CmsFileAuditService.cs b/web/Areas/CMS/Services/CmsFileAuditService.cs new file mode 100644 index 000000000..bd42d93fe --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileAuditService.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Models; +using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +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", System.Globalization.CultureInfo.InvariantCulture), + 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) + { + // ApiPagination admits page=0, and Skip with a negative offset throws; clamp both knobs. + // The upper bound stops a caller from defeating pagination with a giant page size. + page = Math.Max(page, 1); + perPage = Math.Clamp(perPage, 1, 500); + + 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 = DateRangeHelper.ExclusiveUpperBound(filter.To.Value); + query = query.Where(a => a.Timestamp < to); + } + if (!string.IsNullOrEmpty(filter.Search)) + { + query = query.Where(a => 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..1fc9c2270 --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileCrypto.cs @@ -0,0 +1,131 @@ +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"); + } + + /// + /// Key file location honoring the CMS:EncryptionKeyFile override. Non-DI callers + /// (Data.CMS's download path) must use this so an environment that overrides the + /// path decrypts with the same key CmsFileEncryptionService encrypts with. + /// + public static string GetConfiguredKeyFilePath() + { + return HttpHelper.Settings?["CMS:EncryptionKeyFile"] ?? GetDefaultKeyFilePath(); + } + + /// + /// Read the master key (base64 string) from line 2 of the key file. + /// + public static string ReadMasterKey(string keyFilePath) + { + string? masterKey = 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..48b50b868 --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileEncryptionService.cs @@ -0,0 +1,98 @@ +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 = File.ReadAllBytes(filePath); + string tempPath = filePath + ".tmp"; + try + { + File.WriteAllBytes(tempPath, transform(contents)); + File.Move(tempPath, filePath, overwrite: true); + } + finally + { + // Any failure path must remove the temp copy - after a decrypt it holds + // plaintext, and a stray .tmp would otherwise linger in the managed store. + if (File.Exists(tempPath)) + { + CleanUpTempFile(tempPath); + } + } + } + + private static void CleanUpTempFile(string tempPath) + { + try + { + 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/CmsFileImportService.cs b/web/Areas/CMS/Services/CmsFileImportService.cs new file mode 100644 index 000000000..300f61d15 --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileImportService.cs @@ -0,0 +1,415 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +using File = Viper.Models.VIPER.File; + +namespace Viper.Areas.CMS.Services +{ + public interface ICmsFileImportService + { + Task> ImportFilesAsync(CmsFileImportRequest request, CancellationToken ct = default); + + /// Dry-run of an import request: validates each path and reports the resulting names without moving anything. + Task> PreviewImportAsync(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 ILogger _logger; + private readonly string? _legacyWebroot; + + public CmsFileImportService(VIPERContext context, ICmsFileStorageService storage, + ICmsFileEncryptionService encryption, ICmsFileAuditService audit, IUserHelper userHelper, + IConfiguration configuration, ILogger logger) + { + _context = context; + _storage = storage; + _encryption = encryption; + _audit = audit; + _userHelper = userHelper; + _logger = logger; + // The dev default is a Windows path; on any other OS leave it unset so the + // import endpoints fail fast with the explicit not-configured error instead + // of probing a path that cannot exist. + _legacyWebroot = configuration["CMS:LegacyWebrootPath"] + ?? (HttpHelper.Environment?.EnvironmentName == "Development" && OperatingSystem.IsWindows() + ? @"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; + } + + public async Task> PreviewImportAsync(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 results = new List(); + var seenSources = new HashSet(StringComparer.OrdinalIgnoreCase); + var plannedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var rawPath in request.FilePaths.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim())) + { + var result = new CmsFileImportPreviewResult { FilePath = rawPath }; + results.Add(result); + + var source = ResolveSource(rawPath); + if (source.Error != null) + { + result.Message = source.Error; + continue; + } + if (!seenSources.Add(source.SourcePath)) + { + result.Message = "Duplicate of an earlier line."; + continue; + } + + // GetAvailableFileName only sees disk + DB, so two source files sharing a basename + // would both preview the same name. Reserving each planned name (as the sequential + // import writes would) makes a later line resolve to the same unique name the import + // actually assigns it, instead of advertising one it will lose. + string availableOnStore = _storage.GetAvailableFileName(request.Folder, source.FileName); + bool sharesNameWithEarlierLine = plannedNames.Contains(availableOnStore); + string finalName = sharesNameWithEarlierLine + ? _storage.GetAvailableFileName(request.Folder, source.FileName, plannedNames) + : availableOnStore; + string friendlyName = CmsFileNaming.BuildFriendlyName(request.Folder, finalName); + if (await _context.Files.AnyAsync(f => f.FriendlyName == friendlyName, ct)) + { + result.Message = $"A file with the name {friendlyName} already exists."; + continue; + } + plannedNames.Add(finalName); + + result.CanImport = true; + result.FileName = finalName; + if (!string.Equals(finalName, source.FileName, StringComparison.OrdinalIgnoreCase)) + { + result.RenamedFrom = source.FileName; + } + result.FriendlyName = friendlyName; + result.OldUrl = "/" + source.Relative.Replace('\\', '/'); + if (sharesNameWithEarlierLine) + { + result.Message = "Shares a name with an earlier line in this import; renamed to keep both."; + } + } + return results; + } + + private sealed record SourceResolution(string Relative, string SourcePath, string FileName, string? Error); + + /// + /// Resolves a legacy-webroot path and runs the validations shared by preview and + /// import, so the preview can't promise something the import would reject. + /// + private SourceResolution ResolveSource(string rawPath) + { + // Import lines are legacy Windows webroot paths; treat both separator styles as separators + // and rejoin with the host separator so parsing and traversal detection are identical on + // every OS. Path APIs only honor the host separator, so on Linux a "..\" segment would + // otherwise be read as part of a filename and slip past the outside-webroot check. + var segments = rawPath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); + string relative = string.Join('/', segments); + string sourcePath; + string fileName; + try + { + sourcePath = Path.GetFullPath(Path.Join(_legacyWebroot, Path.Join(segments))); + fileName = Path.GetFileName(sourcePath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return new SourceResolution(relative, string.Empty, string.Empty, "Path is not valid."); + } + string webroot = Path.GetFullPath(_legacyWebroot! + Path.DirectorySeparatorChar); + + string? error = null; + if (!sourcePath.StartsWith(webroot, CmsFilePathSafety.PathComparison)) + { + error = "Path is outside the legacy webroot."; + } + else if (!System.IO.File.Exists(sourcePath)) + { + error = "File not found."; + } + else if (!CmsFileTypes.IsAllowedFileName(fileName)) + { + error = "File type is not allowed."; + } + return new SourceResolution(relative, sourcePath, fileName, error); + } + + private async Task ImportSingleFileAsync(string rawPath, CmsFileImportRequest request, + List permissions, CancellationToken ct) + { + var result = new CmsFileImportResult { FilePath = rawPath }; + try + { + var source = ResolveSource(rawPath); + if (source.Error != null) + { + result.Message = source.Error; + return result; + } + string sourcePath = source.SourcePath; + + 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)!); + + string finalPath; + try + { + // Copy inside the try so a partial copy (e.g. disk full) is removed by the finally. + System.IO.File.Copy(sourcePath, tempPath); + if (dbKey != null) + { + _encryption.EncryptFileInPlace(tempPath, dbKey); + } + finalPath = _storage.MoveIntoPlace(tempPath, request.Folder, source.FileName, makeUnique: true); + } + finally + { + if (System.IO.File.Exists(tempPath)) + { + System.IO.File.Delete(tempPath); + } + } + + string friendlyName = CmsFileNaming.BuildFriendlyName(request.Folder, 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 = CmsFileNaming.NormalizeFolderKey(request.Folder), + FriendlyName = friendlyName, + Encrypted = encrypt, + Key = dbKey, + Description = "Automatically imported from Viper", + AllowPublicAccess = request.AllowPublicAccess ?? false, + OldUrl = "/" + source.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 (OperationCanceledException) + { + // A cancelled save never persisted the record: drop the pending entity and the + // stored copy so the abort leaves nothing orphaned, then let it propagate. + _context.ChangeTracker.Clear(); + _storage.DeleteManagedFile(finalPath); + throw; + } + catch (Exception ex) when (ex is DbUpdateException or SqlException or InvalidOperationException) + { + // 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; + } + + try + { + System.IO.File.Delete(sourcePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The record is saved and the managed copy is in place, so the import + // succeeded; only the legacy source could not be removed. Re-importing it + // would now fail on the duplicate name, so report success with a note. + _logger.LogWarning(ex, "CMS import succeeded but the legacy source could not be removed: {Path}", + LogSanitizer.SanitizeString(sourcePath)); + result.Message = "Imported, but the original file could not be removed from the legacy webroot."; + } + + 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; + } + + try + { + string 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)"); + try + { + // 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 or InvalidOperationException) + { + // A save failure (DB, SQL, or invalid entity state) means the key was never + // persisted; the file must be decrypted back or it is unrecoverable. Anything + // else is truly unexpected and is left to propagate. + RollBackUnsavedEncryption(file, dbKey, ex, result); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException + or FormatException or System.Security.Cryptography.CryptographicException or ArgumentException) + { + // Same expected file/key failure set as import: malformed key data (FormatException, + // CryptographicException) or a bad path (ArgumentException) must fail THIS item, not + // unwind the whole batch and drop earlier results. + 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"; + } + } +} diff --git a/web/Areas/CMS/Services/CmsFilePathSafety.cs b/web/Areas/CMS/Services/CmsFilePathSafety.cs index 448745d7c..a5d5f4e9e 100644 --- a/web/Areas/CMS/Services/CmsFilePathSafety.cs +++ b/web/Areas/CMS/Services/CmsFilePathSafety.cs @@ -13,6 +13,14 @@ namespace Viper.Areas.CMS.Services /// public static class CmsFilePathSafety { + /// + /// Comparison for path-containment checks: Windows paths are case-insensitive, but on a + /// case-sensitive filesystem an ignore-case match would treat a differently-cased sibling + /// (e.g. /srv/Files vs /srv/files) as under the root and weaken the containment guarantee. + /// + public static StringComparison PathComparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private const string DefaultDownloadName = "FileDownload.zip"; private static readonly HashSet ReservedWindowsNames = new(StringComparer.OrdinalIgnoreCase) diff --git a/web/Areas/CMS/Services/CmsFileResponse.cs b/web/Areas/CMS/Services/CmsFileResponse.cs new file mode 100644 index 000000000..5b740423d --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileResponse.cs @@ -0,0 +1,28 @@ +using Microsoft.Net.Http.Headers; +using Viper.Areas.CMS.Constants; + +namespace Viper.Areas.CMS.Services +{ + /// + /// Response hardening shared by the CMS file download paths (/CMS/Files). Every file response + /// gets X-Content-Type-Options: nosniff so a benign-typed upload cannot be MIME-sniffed into an + /// active type, and inline-unsafe types (html/svg/...) are forced to download instead of + /// rendering in the app origin. + /// + public static class CmsFileResponse + { + public static void SetNoSniff(HttpResponse response) + { + response.Headers[HeaderNames.XContentTypeOptions] = "nosniff"; + } + + /// + /// Content-Disposition type for a served file: "attachment" for types a browser would + /// execute/render inline, "inline" for everything else. + /// + public static string DispositionType(string fileName) + { + return CmsFileTypes.ShouldForceAttachment(fileName) ? "attachment" : "inline"; + } + } +} diff --git a/web/Areas/CMS/Services/CmsFileService.cs b/web/Areas/CMS/Services/CmsFileService.cs new file mode 100644 index 000000000..9d26999c1 --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileService.cs @@ -0,0 +1,897 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Models; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +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, + string? restrictDeletedToOwner = null, CancellationToken ct = default); + + Task> GetFolderCountsAsync(string status, bool? encrypted, bool? isPublic, + string? restrictDeletedToOwner = null, CancellationToken ct = default); + + Task CheckNameAsync(string folder, string fileName, 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); + + /// + /// Permanently deletes trashed files whose DeletedOn is older than . + /// Used by the trash-purge scheduled job. Returns the number of files purged. + /// + Task PurgeDeletedFilesAsync(int retentionDays, 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; + private readonly ILogger _logger; + + public CmsFileService(VIPERContext context, AAUDContext aaudContext, ICmsFileStorageService storage, + ICmsFileEncryptionService encryption, ICmsFileAuditService audit, IUserHelper userHelper, + ILogger logger) + { + _context = context; + _aaudContext = aaudContext; + _storage = storage; + _encryption = encryption; + _audit = audit; + _userHelper = userHelper; + _logger = logger; + } + + 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, + string? restrictDeletedToOwner = null, CancellationToken ct = default) + { + // ApiPagination admits page=0, and Skip with a negative offset throws; clamp both knobs. + // The upper bound stops a caller from defeating pagination with a giant page size. + page = Math.Max(page, 1); + perPage = Math.Clamp(perPage, 1, 500); + + 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. + // New records store the canonical '\' form (NormalizeFolderKey), but legacy rows + // may carry '/' separators; match subfolders in either style. + var folderPrefix = folder + @"\"; + var folderPrefixAlt = folder + "/"; + query = query.Where(f => f.Folder == folder || (f.Folder != null + && (f.Folder.StartsWith(folderPrefix) || f.Folder.StartsWith(folderPrefixAlt)))); + } + + query = ApplyStatusFilter(query, status, restrictDeletedToOwner); + + 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), + // Trash views: ascending = closest to the purge cutoff first. + ("deletedon", false) => query.OrderBy(f => f.DeletedOn).ThenBy(f => f.FriendlyName), + ("deletedon", true) => query.OrderByDescending(f => f.DeletedOn).ThenBy(f => f.FriendlyName), + (_, 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> GetFolderCountsAsync(string status, bool? encrypted, bool? isPublic, + string? restrictDeletedToOwner = null, CancellationToken ct = default) + { + var query = _context.Files + .AsNoTracking() + .TagWith("CmsFileService.GetFolderCounts") + .AsQueryable(); + + query = ApplyStatusFilter(query, status, restrictDeletedToOwner); + + if (encrypted != null) + { + query = query.Where(f => f.Encrypted == encrypted); + } + + if (isPublic != null) + { + query = query.Where(f => f.AllowPublicAccess == isPublic); + } + + var byFolder = await query + .GroupBy(f => f.Folder) + .Select(g => new { g.Key, Count = g.Count() }) + .ToListAsync(ct); + + // Folders may have subfolders stored as "folder\sub"; roll counts up to the + // top-level folder so they match the list's folder filter (GetFilesAsync). + return byFolder + .GroupBy(x => (x.Key ?? string.Empty).Split(['\\', '/'], StringSplitOptions.None)[0], StringComparer.OrdinalIgnoreCase) + .Where(g => !string.IsNullOrWhiteSpace(g.Key)) + .Select(g => new CmsFolderCountDto { Folder = g.Key, Count = g.Sum(x => x.Count) }) + .OrderBy(c => c.Folder, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + // restrictDeletedToOwner scopes deleted files to the login that deleted them (ModifiedBy), + // so non-admins only see files they trashed. Admins pass null and see the whole trash. + private static IQueryable ApplyStatusFilter(IQueryable query, string? status, + string? restrictDeletedToOwner = null) + { + return status?.ToLowerInvariant() switch + { + "active" => query.Where(f => f.DeletedOn == null), + "deleted" => restrictDeletedToOwner == null + ? query.Where(f => f.DeletedOn != null) + : query.Where(f => f.DeletedOn != null && f.ModifiedBy == restrictDeletedToOwner), + _ => restrictDeletedToOwner == null + ? query + : query.Where(f => f.DeletedOn == null || f.ModifiedBy == restrictDeletedToOwner) + }; + } + + 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 CheckNameAsync(string folder, string fileName, CancellationToken ct = default) + { + if (!_storage.IsValidFolder(folder)) + { + throw new ArgumentException("Invalid folder."); + } + // Both-separator leaf: Path.GetFileName is OS-dependent and would keep a "sub\name" + // prefix on Linux, leaking path separators into SuggestedName. + string name = CmsFileNaming.GetLeafName(fileName); + if (string.IsNullOrWhiteSpace(name) || !CmsFileTypes.IsAllowedFileName(name)) + { + throw new ArgumentException("File type is not allowed."); + } + + string targetPath = _storage.BuildManagedPath(folder, name); + // Match by path OR derived friendly name: a record created under another + // environment's storage root carries a different FilePath but the same friendly + // name, while a record whose friendly name was later edited still matches by path. + string derivedFriendlyName = CmsFileNaming.BuildFriendlyName(folder, name); + var existing = await _context.Files + .AsNoTracking() + .FirstOrDefaultAsync(f => f.FilePath == targetPath || f.FriendlyName == derivedFriendlyName, ct); + bool inUse = existing != null || _storage.FileNameInUse(folder, name); + return new CmsFileNameCheckDto + { + InUse = inUse, + SuggestedName = inUse ? _storage.GetAvailableFileName(folder, name) : name, + ExistingFileGuid = existing?.FileGuid, + ExistingFriendlyName = existing?.FriendlyName, + ExistingDeleted = existing?.DeletedOn != null, + ExistingModifiedOn = existing?.ModifiedOn + }; + } + + public async Task CreateFileAsync(CmsFileCreateRequest request, IFormFile file, CancellationToken ct = default) + { + if (!_storage.IsValidFolder(request.Folder)) + { + throw new ArgumentException("Invalid folder."); + } + // Both-separator leaf: some clients send Windows-style paths (C:\fakepath\name.pdf) + // that Path.GetFileName would keep intact on Linux. + string uploadName = CmsFileNaming.GetLeafName( + string.IsNullOrWhiteSpace(request.FileName) ? file.FileName : request.FileName); + if (string.IsNullOrWhiteSpace(uploadName) || !CmsFileTypes.IsAllowedFileName(uploadName)) + { + 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; + // Overwriting an orphaned on-disk file (no DB row) destroys its bytes; snapshot them first + // so a later failure can roll the original back instead of leaving nothing in its place. + string? overwriteBackup = null; + try + { + if (dbKey != null) + { + _encryption.EncryptFileInPlace(tempPath, dbKey); + } + if (request.Overwrite == true && _storage.FileNameInUse(request.Folder, uploadName)) + { + string targetPath = _storage.BuildManagedPath(request.Folder, uploadName); + // Match by path OR derived friendly name so a record created under another + // environment's storage root blocks the overwrite BEFORE any bytes are + // touched, instead of failing later on friendly-name uniqueness and rolling + // back an already-performed disk swap. + string overwriteFriendlyName = CmsFileNaming.BuildFriendlyName(request.Folder, uploadName); + if (await _context.Files.AnyAsync(f => f.FilePath == targetPath || f.FriendlyName == overwriteFriendlyName, ct)) + { + throw new InvalidOperationException( + $"{uploadName} belongs to an existing file record; replace its content by editing that file."); + } + if (_storage.ManagedFileExists(targetPath)) + { + overwriteBackup = _storage.BackupManagedFile(targetPath); + } + try + { + _storage.ReplaceInPlace(tempPath, targetPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // The replace may have destroyed the original bytes mid-swap; restore the + // backup (or remove a partial copy) so the store stays consistent. + ReconcileStoreAfterFailedCreate(targetPath, overwriteBackup); + throw; + } + finalPath = targetPath; + } + else + { + finalPath = _storage.MoveIntoPlace(tempPath, request.Folder, uploadName, makeUnique: false); + } + } + finally + { + if (System.IO.File.Exists(tempPath)) + { + System.IO.File.Delete(tempPath); + } + } + + string friendlyName = CmsFileNaming.BuildFriendlyName(request.Folder, Path.GetFileName(finalPath)); + if (await _context.Files.AnyAsync(f => f.FriendlyName == friendlyName, ct)) + { + // The new bytes are already on disk; reconcile the store (restore an overwritten + // original, or delete a fresh copy) before failing so nothing is left behind. + ReconcileStoreAfterFailedCreate(finalPath, overwriteBackup); + throw new InvalidOperationException($"A file with the name {friendlyName} already exists."); + } + + var entity = new File + { + FileGuid = Guid.NewGuid(), + FilePath = finalPath, + Folder = CmsFileNaming.NormalizeFolderKey(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"); + bool saved = false; + try + { + await _context.SaveChangesAsync(ct); + saved = true; + } + catch (Exception ex) when (ex is DbUpdateException or SqlException or InvalidOperationException or OperationCanceledException) + { + // A failed or cancelled save never persisted the record. Drop the pending changes, then + // reconcile the managed store: restore an overwritten file's original bytes, or remove the + // freshly stored copy, so nothing is left orphaned or destroyed. No log here (S2139: + // the rethrown exception is logged by its handler); reconcile failures log critically. + _context.ChangeTracker.Clear(); + ReconcileStoreAfterFailedCreate(finalPath, overwriteBackup); + throw; + } + finally + { + // Only clean up on a committed save (the backup is then obsolete). On a failed save the + // reconcile above owns the backup: a successful restore already consumed it via Move, and + // a FAILED restore must keep it as the last copy of the original bytes. + if (saved && overwriteBackup != null && System.IO.File.Exists(overwriteBackup)) + { + System.IO.File.Delete(overwriteBackup); + } + } + + 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; + } + + // Stale-edit guard first, before anything touches the file on disk (mirrors + // CmsContentBlockService: missing value -> 400, stale value -> 409). + AssertNotStale(entity, request.LastModifiedOn); + + // Validate a replacement upload before anything touches the file on disk, so a bad type + // can't leave the file half-transformed or strand a backup copy. + if (file != null) + { + ValidateReplacementUpload(entity, file); + } + + // The crypto toggle and any replacement upload below transform the file on disk before + // the metadata save. Capture the pre-change state so a failed save can reconcile the + // on-disk file back to it (mirrors the import service's encryption rollback). + bool originalEncrypted = entity.Encrypted; + string? originalKey = entity.Key; + + // A replacement overwrites the bytes on disk before the save commits. Snapshot the + // original file first (it carries the original encryption state too) so a failed save + // can roll it back; restoring it supersedes the crypto-only reconcile below. + string? originalFileBackup = file != null && _storage.ManagedFileExists(entity.FilePath) + ? _storage.BackupManagedFile(entity.FilePath) + : null; + + var changes = new List(); + // A pending replacement writes the new bytes already in the target crypto state, + // so transforming the old bytes it is about to overwrite would be wasted work. + ApplyEncryptionToggle(entity, request.Encrypt ?? false, changes, transformDisk: file == null); + ApplyScalarFieldChanges(entity, request, changes); + + ApplyPermissionDeltas(entity, CleanList(request.Permissions), changes); + ApplyPersonDeltas(entity, CleanList(request.IamIds), changes); + + if (file != null) + { + await WriteReplacementUploadAsync(entity, file, changes, ct); + } + + entity.ModifiedOn = DateTime.Now; + entity.ModifiedBy = CurrentLoginId(); + + _audit.Audit(entity, CmsFileAuditActions.EditFile, string.Join("; ", changes)); + await SaveUpdateAndReconcileAsync(entity, originalFileBackup, originalEncrypted, originalKey); + + var names = await GetNamesByIamIdAsync(entity.FileToPeople.Select(p => p.IamId), ct); + return CmsFileMapper.ToCmsFileDto(entity, names); + } + + private static void AssertNotStale(File entity, DateTime? lastModifiedOn) => + CmsServiceHelpers.AssertNotStale("file", entity.ModifiedOn, entity.ModifiedBy, lastModifiedOn); + + /// + /// A replacement upload keeps the record's name and path, so its bytes must be an allowed + /// type and match the original extension; otherwise the stored file would hold mismatched + /// content and serve a corrupt download. Runs before anything touches disk. + /// + private static void ValidateReplacementUpload(File entity, IFormFile file) + { + if (!CmsFileTypes.IsAllowedFileName(file.FileName)) + { + throw new ArgumentException("File type is not allowed."); + } + string replacementExt = Path.GetExtension(entity.FilePath); + if (!string.Equals(replacementExt, Path.GetExtension(file.FileName), StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"The replacement file must be the same type as the original ({replacementExt})."); + } + } + + /// + /// Applies the encryption toggle in place before any replacement upload, so the replacement + /// is written with the file's final encryption state. Records the change for the audit trail. + /// + private void ApplyEncryptionToggle(File entity, bool encrypt, List changes, bool transformDisk) + { + if (encrypt && !entity.Encrypted) + { + string dbKey = _encryption.GenerateKeyForDb(); + if (transformDisk && _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 (transformDisk && !string.IsNullOrEmpty(entity.Key) && _storage.ManagedFileExists(entity.FilePath)) + { + _encryption.DecryptFileInPlace(entity.FilePath, entity.Key); + } + entity.Key = null; + entity.Encrypted = false; + changes.Add("Decrypted file"); + } + } + + /// + /// Applies the description, public-access, and Old URL edits, recording each that actually + /// changes for the audit trail. + /// + private static void ApplyScalarFieldChanges(File entity, CmsFileUpdateRequest request, List changes) + { + string newDescription = request.Description ?? string.Empty; + if (entity.Description != newDescription) + { + entity.Description = newDescription; + changes.Add("Description updated"); + } + bool allowPublicAccess = request.AllowPublicAccess ?? false; + 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"); + } + } + + /// + /// Writes a replacement upload over the record's file: encrypts the temp copy to the file's + /// current state if needed, swaps it in, audits the replacement, and revives a soft-deleted + /// record (the overwrite-conflict flow). The temp copy is always cleaned up. + /// + private async Task WriteReplacementUploadAsync(File entity, IFormFile file, List changes, CancellationToken ct) + { + 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"); + + // Uploading new content into a soft-deleted record (the overwrite-conflict flow) makes + // it live again; metadata-only edits leave deletion state alone. + if (entity.DeletedOn != null) + { + entity.DeletedOn = null; + changes.Add("Restored file"); + } + } + + /// + /// Persists the metadata edit, reconciling the on-disk file when the save fails: restore the + /// pre-replacement backup (which also restores the original encryption state) or, with no + /// replacement, revert just the crypto transform. The backup is deleted once the save commits; + /// if a restore fails it is left in place as the last copy of the original bytes. + /// + private async Task SaveUpdateAndReconcileAsync(File entity, string? originalFileBackup, + bool originalEncrypted, string? originalKey) + { + bool saved = false; + try + { + // The file on disk may already be in its new encryption state; cancelling the save + // would strand it without a matching key, so it runs to completion even if aborted. + await _context.SaveChangesAsync(CancellationToken.None); + saved = true; + } + catch (Exception ex) when (ex is DbUpdateException or SqlException or InvalidOperationException) + { + // A database save failure means the new state was not persisted; drop the pending + // changes and reconcile the on-disk file back to its original state before rethrowing. + // No log here (S2139: the rethrown exception is logged by its handler); reconcile + // failures log critically in the revert helpers. + _context.ChangeTracker.Clear(); + if (originalFileBackup != null) + { + // A replacement overwrote the file; restoring the original bytes also restores + // the original encryption state, so the crypto-only reconcile is skipped. + RestoreOriginalFileOnSaveFailure(entity, originalFileBackup); + } + else + { + RevertCryptoOnSaveFailure(entity, originalEncrypted, originalKey); + } + throw; + } + finally + { + // Only clean up on a committed save (the backup is then obsolete). On a failed save the + // reconcile above owns the backup: a successful restore already consumed it via Move, and + // a FAILED restore must keep it as the last copy of the original bytes. + if (saved && originalFileBackup != null && System.IO.File.Exists(originalFileBackup)) + { + System.IO.File.Delete(originalFileBackup); + } + } + } + + /// + /// A failed metadata save rolls the database back to the file's original encryption state, + /// but the on-disk file was already transformed to the new state. Reconcile it back, or a + /// download would read bytes that don't match the stored key. A failure to revert leaves the + /// file unreadable, so it is logged as critical rather than swallowed. + /// + private void RevertCryptoOnSaveFailure(File entity, bool originalEncrypted, string? originalKey) + { + if (entity.Encrypted == originalEncrypted || !_storage.ManagedFileExists(entity.FilePath)) + { + return; + } + try + { + if (originalEncrypted && !string.IsNullOrEmpty(originalKey)) + { + // The file had been decrypted; re-encrypt it with the original key. + _encryption.EncryptFileInPlace(entity.FilePath, originalKey); + } + else if (!originalEncrypted && !string.IsNullOrEmpty(entity.Key)) + { + // The file had been encrypted; decrypt it with the key that was generated. + _encryption.DecryptFileInPlace(entity.FilePath, entity.Key); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogCritical(ex, "CMS file {FileGuid} could not be reverted after a failed save; " + + "its on-disk encryption no longer matches the database.", entity.FileGuid); + } + } + + /// + /// A failed save after a replacement upload leaves the new bytes on disk while the database + /// rolled back to the original record. Move the pre-replacement backup back into place so a + /// download serves the original content (and its original encryption state). A failure to + /// restore is logged as critical (with the preserved backup path) rather than swallowed; the + /// caller leaves the backup in place so it remains the last copy of the original bytes. + /// + private void RestoreOriginalFileOnSaveFailure(File entity, string backupPath) + { + try + { + _storage.ReplaceInPlace(backupPath, entity.FilePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + _logger.LogCritical(ex, "CMS file {FileGuid} could not be restored after a failed save; " + + "the on-disk content may not match the database. The original bytes are preserved " + + "at {BackupPath} for manual recovery.", entity.FileGuid, backupPath); + } + } + + /// + /// A create that has already written its bytes but then fails (friendly-name clash or save + /// error) must not leave the store changed. When the write overwrote an orphaned file, move + /// its pre-overwrite backup back so the original content survives; otherwise just delete the + /// freshly stored copy. A failed restore is logged as critical (with the preserved backup + /// path) rather than swallowed; the caller leaves the backup in place for recovery. + /// + private void ReconcileStoreAfterFailedCreate(string finalPath, string? overwriteBackup) + { + if (overwriteBackup == null) + { + _storage.DeleteManagedFile(finalPath); + return; + } + try + { + _storage.ReplaceInPlace(overwriteBackup, finalPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + _logger.LogCritical(ex, "CMS overwrite at {FilePath} could not be restored after a failed " + + "create; the original bytes are preserved at {BackupPath} for manual recovery.", + LogSanitizer.SanitizeString(finalPath), overwriteBackup); + } + } + + 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"); + // Remove every dependent row before the File delete: content-block attachments use + // ClientSetNull (no DB cascade), so a still-attached file would otherwise trip the FK. + // Purging a still-attached file detaches it from those blocks. + var blockLinks = await _context.ContentBlockToFiles.Where(l => l.FileGuid == fileGuid).ToListAsync(ct); + _context.RemoveRange(blockLinks); + _context.RemoveRange(entity.FileToPermissions); + _context.RemoveRange(entity.FileToPeople); + _context.Remove(entity); + await _context.SaveChangesAsync(ct); + + // The DB row is already gone at this point, so a failed disk delete only strands bytes; + // log it for manual cleanup rather than failing an operation that did delete the record. + try + { + if (_storage.ManagedFileExists(entity.FilePath)) + { + _storage.DeleteManagedFile(entity.FilePath); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogError(ex, "CMS file record {FileGuid} was deleted but its bytes at {FilePath} " + + "could not be removed from disk.", + fileGuid, LogSanitizer.SanitizeString(entity.FilePath)); + } + return true; + } + + public async Task PurgeDeletedFilesAsync(int retentionDays, CancellationToken ct = default) + { + var cutoff = DateTime.Now.AddDays(-retentionDays); + var guids = await _context.Files + .Where(f => f.DeletedOn != null && f.DeletedOn < cutoff) + .Select(f => f.FileGuid) + .ToListAsync(ct); + + int purged = 0; + foreach (var guid in guids) + { + try + { + if (await PermanentlyDeleteFileAsync(guid, ct)) + { + purged++; + } + } + catch (Exception ex) when (ex is DbUpdateException or SqlException or IOException or InvalidOperationException) + { + // Isolate per-file failures so one bad file doesn't abort the whole purge run. + _context.ChangeTracker.Clear(); + _logger.LogError(ex, "CMS trash purge failed to delete file {FileGuid}", guid); + } + } + return purged; + } + + 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, CmsFilePathSafety.PathComparison)) + { + // Records created on another OS/environment may carry either separator style. + int filesIndex = filePath.IndexOf(@"\Files\", StringComparison.OrdinalIgnoreCase); + if (filesIndex < 0) + { + filesIndex = filePath.IndexOf("/Files/", StringComparison.OrdinalIgnoreCase); + } + if (filesIndex >= 0) + { + string rest = filePath[(filesIndex + @"\Files".Length)..] + .Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar); + return root + rest; + } + } + 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 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) => CmsServiceHelpers.CleanList(values); + + 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..2b4f8dd1a --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileStorageService.cs @@ -0,0 +1,337 @@ +using Microsoft.EntityFrameworkCore; +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(); + + /// + /// Top-level folders for FILTERING: the upload allow-list (disk) unioned with + /// folders that only exist on file records (e.g. created on another environment's + /// disk). Not valid as upload destinations. + /// + Task> GetFilterFoldersAsync(CancellationToken ct = default); + + /// + /// 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); + + /// + /// The name MoveIntoPlace would store folder\fileName under: the name itself when free, + /// otherwise the first free name_0..name_999 candidate. + /// (optional) are treated as already taken, so callers planning several writes in one + /// batch get the same unique names the sequential moves would actually assign. + /// + string GetAvailableFileName(string folder, string fileName, IReadOnlySet? reservedNames = null); + + /// Resolved managed path for folder\fileName (validated to stay under the root). + string BuildManagedPath(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); + + /// + /// Copy a managed file to a temp backup outside the storage root and return its path. Pair + /// with ReplaceInPlace(backupPath, originalPath) to roll the original bytes back into place + /// after a failed save. + /// + string BackupManagedFile(string filePath); + + /// 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(); + } + // DirectoryInfo.Name gives the (non-null) leaf folder name directly, avoiding the + // Path.GetFileName nullable return that would otherwise need coalescing. + return new DirectoryInfo(RootFolder).GetDirectories() + .Select(d => d.Name) + .Where(d => !string.IsNullOrEmpty(d)) + .OrderBy(d => d) + .ToList(); + } + + public async Task> GetFilterFoldersAsync(CancellationToken ct = default) + { + var dbFolders = await _context.Files.Select(f => f.Folder).Distinct().ToListAsync(ct); + var topLevel = dbFolders + .Where(f => !string.IsNullOrWhiteSpace(f)) + .Select(f => f!.Split(['\\', '/'], StringSplitOptions.None)[0]); + return GetTopLevelFolders() + .Union(topLevel, StringComparer.OrdinalIgnoreCase) + .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) + .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 == ".." + || HasInvalidNameChar(s))) + { + 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 (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. Records + // created under another environment's storage root (ReplaceRootFolder rewrites + // them at read time) claim the same folder+name, so match the path suffix in + // either separator style rather than only the exact current-root path. + string[] segments = folder.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries); + string leaf = GetLeafName(fileName); + string suffixBack = "\\" + string.Join('\\', segments) + "\\" + leaf; + string suffixFwd = "/" + string.Join('/', segments) + "/" + leaf; + return _context.Files.Any(f => f.FilePath == targetPath + || f.FilePath.EndsWith(suffixBack) || f.FilePath.EndsWith(suffixFwd)); + } + + public string GetAvailableFileName(string folder, string fileName, IReadOnlySet? reservedNames = null) + { + string finalName = GetLeafName(fileName); + bool taken = FileNameInUse(folder, finalName) + || (reservedNames != null && reservedNames.Contains(finalName)); + return taken ? GetUniqueFileName(folder, finalName, reservedNames) : finalName; + } + + public string BuildManagedPath(string folder, string fileName) + { + return BuildTargetPath(folder, fileName); + } + + 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")); + // useAsync so CopyToAsync performs true async I/O instead of blocking a request thread. + await using FileStream stream = new(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, bufferSize: 81920, useAsync: true); + 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 = GetLeafName(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)!); + File.Move(tempPath, targetPath); + return targetPath; + } + + public void ReplaceInPlace(string tempPath, string existingFilePath) + { + AssertUnderRoot(existingFilePath); + File.Move(tempPath, existingFilePath, overwrite: true); + } + + public string BackupManagedFile(string filePath) + { + AssertUnderRoot(filePath); + string tempFolder = Path.Join(Path.GetTempPath(), "Viper-CMS-Uploads"); + System.IO.Directory.CreateDirectory(tempFolder); + string backupPath = Path.Join(tempFolder, Guid.NewGuid().ToString("N")); + File.Copy(filePath, backupPath, overwrite: true); + return backupPath; + } + + public void DeleteManagedFile(string filePath) + { + AssertUnderRoot(filePath); + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + + public bool ManagedFileExists(string filePath) + { + return IsUnderRoot(filePath) && File.Exists(filePath); + } + + private string GetUniqueFileName(string folder, string fileName, IReadOnlySet? reservedNames = null) + { + string baseName = Path.GetFileNameWithoutExtension(fileName); + string extension = Path.GetExtension(fileName); + // Preload the folder's taken names once (one disk listing + one DB query) so the + // legacy _0.._999 probe loops in memory instead of issuing a query per candidate. + var taken = GetTakenLeafNames(folder); + for (int i = 0; i < 1000; i++) + { + string candidate = $"{baseName}_{i}{extension}"; + if (!taken.Contains(candidate) + && (reservedNames == null || !reservedNames.Contains(candidate)) + // Final single-name confirmation guards the race with a concurrent upload. + && !FileNameInUse(folder, candidate)) + { + return candidate; + } + } + throw new InvalidOperationException($"Unable to generate a unique name for {fileName} in {folder}."); + } + + /// + /// Every leaf name already claimed in a folder: files on disk plus DB records whose + /// Folder matches in either separator style (their FilePath may carry another + /// environment's storage root, so the path itself is not compared). + /// + private HashSet GetTakenLeafNames(string folder) + { + var taken = new HashSet(StringComparer.OrdinalIgnoreCase); + + string[] segments = folder.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries); + string dirPath = Path.GetFullPath(Path.Join(RootFolder, Path.Join(segments))); + // System.IO qualified: the sibling Viper.Areas.Directory namespace shadows it here. + if (IsUnderRoot(dirPath) && System.IO.Directory.Exists(dirPath)) + { + foreach (var leaf in System.IO.Directory.EnumerateFiles(dirPath) + .Select(Path.GetFileName) + .Where(leaf => !string.IsNullOrEmpty(leaf))) + { + taken.Add(leaf!); + } + } + + string canonBack = string.Join('\\', segments); + string canonFwd = string.Join('/', segments); + var dbPaths = _context.Files + .Where(f => f.Folder == canonBack || f.Folder == canonFwd) + .Select(f => f.FilePath) + .ToList(); + foreach (var path in dbPaths) + { + taken.Add(CmsFileNaming.GetLeafName(path)); + } + return taken; + } + + private string BuildTargetPath(string folder, string fileName) + { + // Split the folder on both separator styles and rejoin with the host separator so a + // user-supplied "sub\dir" resolves to real nested folders on Linux too (Path.Join would + // otherwise treat "sub\dir" as one literal directory name there). + string[] folderSegments = folder.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries); + string path = Path.GetFullPath(Path.Join(RootFolder, Path.Join(folderSegments), GetLeafName(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, CmsFilePathSafety.PathComparison); + } + + // Both-separator leaf extraction lives in CmsFileNaming so callers outside the + // storage service (e.g. the check-name endpoint) strip path components identically. + private static string GetLeafName(string name) + { + return CmsFileNaming.GetLeafName(name); + } + + // Reject on an explicit, OS-independent set rather than Path.GetInvalidFileNameChars, which + // on Linux returns only { '\0', '/' } and would let '<', '>', ':' etc. through. Stricter than + // the host filesystem on purpose (defense in depth). + private static readonly char[] InvalidNameChars = ['<', '>', ':', '"', '|', '?', '*']; + + private static bool HasInvalidNameChar(string segment) + { + return segment.Any(c => c < ' ' || Array.IndexOf(InvalidNameChars, c) >= 0); + } + } +} diff --git a/web/Areas/CMS/Services/CmsServiceHelpers.cs b/web/Areas/CMS/Services/CmsServiceHelpers.cs new file mode 100644 index 000000000..483266ae2 --- /dev/null +++ b/web/Areas/CMS/Services/CmsServiceHelpers.cs @@ -0,0 +1,41 @@ +namespace Viper.Areas.CMS.Services +{ + /// + /// Helpers shared by the CMS write services (files, content blocks, left navs) so the + /// optimistic-concurrency contract and permission-list normalization cannot drift between + /// copies. Each service wraps these with its own entity-typed one-liner. + /// + internal static class CmsServiceHelpers + { + /// + /// A missing stamp is a 400 (the client must send it) and a stale one is a 409 (someone + /// saved after the editor loaded). Compared to the second: serialized timestamps lose + /// sub-second precision round-tripping through the client. + /// + public static void AssertNotStale(string noun, DateTime modifiedOn, string? modifiedBy, DateTime? lastModifiedOn) + { + if (lastModifiedOn == null) + { + throw new ArgumentException("LastModifiedOn is required so concurrent edits can be detected."); + } + if (Math.Abs((modifiedOn - lastModifiedOn.Value).TotalSeconds) >= 1) + { + throw new CmsConcurrencyException( + $"This {noun} was modified by {modifiedBy} on {modifiedOn:g}. Reload to get the latest version."); + } + } + + /// + /// Trim, drop blanks, and de-duplicate case-insensitively. A client can post + /// "permissions": null explicitly; treat it as empty rather than 500. + /// + public static List CleanList(IEnumerable? values) + { + return (values ?? []) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + } +} diff --git a/web/Areas/CMS/Services/CmsUserPhotoService.cs b/web/Areas/CMS/Services/CmsUserPhotoService.cs new file mode 100644 index 000000000..c778c5565 --- /dev/null +++ b/web/Areas/CMS/Services/CmsUserPhotoService.cs @@ -0,0 +1,158 @@ +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 (MailId, LoginId, IamId, or MothraId). 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. The result carries a Last-Modified value for conditional caching. + /// + Task GetUserPhotoAsync(string? mailId, string? loginId, string? iamId, string? mothraId, + bool preferAltPhoto, CancellationToken ct = default); + } + + /// + /// A served photo's bytes plus the timestamp to publish as Last-Modified/use for + /// If-Modified-Since comparisons. Always truncated to whole seconds, since HTTP dates + /// have second precision. + /// + public sealed record CmsUserPhotoResult(byte[] Bytes, DateTimeOffset LastModified); + + /// + /// 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; + + // The delegated Students photo pipeline (id-card photo and its nopic fallback) doesn't + // expose a per-file timestamp, so those two outcomes share this stable per-process + // proxy for Last-Modified: it only changes on deploy/restart, which is exactly when a + // browser-cached copy should be treated as stale. + private static readonly DateTimeOffset DelegatedPhotoLastModified = TruncateToSeconds(DateTimeOffset.UtcNow); + + [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, + string? mothraId, 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, mothraId, needIamId: preferAltPhoto, ct); + + if (preferAltPhoto && iamId != null) + { + var altPhoto = await ReadAltPhotoAsync(iamId, ct); + if (altPhoto != null) + { + return altPhoto; + } + } + + var bytes = await _photoService.GetStudentPhotoAsync(mailId ?? string.Empty); + return new CmsUserPhotoResult(bytes, DelegatedPhotoLastModified); + } + + private async Task<(string? MailId, string? IamId)> ResolveIdsAsync(string? mailId, string? loginId, + string? iamId, string? mothraId, 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 if (!string.IsNullOrEmpty(mothraId)) + { + query = query.Where(u => u.MothraId == mothraId); + } + 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, CmsFilePathSafety.PathComparison) || !File.Exists(photoPath)) + { + return null; + } + + try + { + var bytes = await File.ReadAllBytesAsync(photoPath, ct); + var lastModified = TruncateToSeconds(new DateTimeOffset(File.GetLastWriteTimeUtc(photoPath), TimeSpan.Zero)); + return new CmsUserPhotoResult(bytes, lastModified); + } + 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; + } + } + + // HTTP dates (and If-Modified-Since comparisons) only have second precision; truncating + // here keeps a stable value fed back on the next request's comparison. + private static DateTimeOffset TruncateToSeconds(DateTimeOffset value) + { + return value.AddTicks(-(value.UtcTicks % TimeSpan.TicksPerSecond)); + } + } +} diff --git a/web/Areas/CMS/Validation/MaxLengthEachAttribute.cs b/web/Areas/CMS/Validation/MaxLengthEachAttribute.cs new file mode 100644 index 000000000..935160780 --- /dev/null +++ b/web/Areas/CMS/Validation/MaxLengthEachAttribute.cs @@ -0,0 +1,47 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations; + +namespace Viper.Areas.CMS.Validation; + +/// +/// Validates that every string element in a collection is at most characters. +/// [MaxLength] on a collection property bounds only the element count, not each element, so this +/// guards individual entries against their backing column size (e.g. permission strings vs the +/// varchar(500) LeftNavItemToPermission.permission column). +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public sealed class MaxLengthEachAttribute : ValidationAttribute +{ + public MaxLengthEachAttribute(int length) + { + Length = length; + } + + public int Length { get; } + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + // A string is IEnumerable, so applying this attribute to a string property would + // silently iterate characters and never enforce Length; fail loudly instead. + if (value is string) + { + throw new InvalidOperationException( + $"{nameof(MaxLengthEachAttribute)} applies to collections of strings; use [MaxLength] for a string property."); + } + + if (value is not IEnumerable items) + { + return ValidationResult.Success; + } + + foreach (var item in items) + { + if (item is string s && s.Length > Length) + { + return new ValidationResult($"Each value must be {Length} characters or fewer."); + } + } + + return ValidationResult.Success; + } +} diff --git a/web/Areas/CMS/Validation/SafeUrlAttribute.cs b/web/Areas/CMS/Validation/SafeUrlAttribute.cs index 8b7f893d7..72c90357c 100644 --- a/web/Areas/CMS/Validation/SafeUrlAttribute.cs +++ b/web/Areas/CMS/Validation/SafeUrlAttribute.cs @@ -1,12 +1,18 @@ using System.ComponentModel.DataAnnotations; -namespace Areas.CMS.Validation; +namespace Viper.Areas.CMS.Validation; [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] 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,39 @@ 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."); + if (!AllowRelative) + { + return new ValidationResult("URL must be a full address starting with http, https, mailto, or tel."); + } + + // Reject protocol-relative ("network-path") references like "//evil.example/x": + // a browser navigates those off-site even though they carry no scheme. Backslashes + // are normalized to forward slashes when parsing URLs, so "/\", "\\", and "\/" are + // equivalent bypasses and must be rejected too. + if (url.Length >= 2 && (url[0] == '/' || url[0] == '\\') && (url[1] == '/' || url[1] == '\\')) + { + return new ValidationResult("URL must not start with // (that would point to another site)."); + } + + return ValidationResult.Success; } - 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."); } } diff --git a/web/Classes/Utilities/DateRangeHelper.cs b/web/Classes/Utilities/DateRangeHelper.cs new file mode 100644 index 000000000..e756c5c8a --- /dev/null +++ b/web/Classes/Utilities/DateRangeHelper.cs @@ -0,0 +1,33 @@ +namespace Viper.Classes.Utilities; + +/// +/// Helpers for date-range query filters. +/// +public static class DateRangeHelper +{ + /// + /// Exclusive upper bound for an inclusive "To" date filter. A value with no time component + /// (midnight) is treated as "through the end of that day", so the next midnight is returned; + /// a value that already carries a time of day is returned unchanged. Compare with + /// < against the result. + /// + public static DateTime ExclusiveUpperBound(DateTime to) + { + // A value carrying a time of day is already an exclusive bound - return it unchanged + // (checked first so a ceiling-day timestamp is not widened to end of time). + if (to.Date != to) + { + return to; + } + + // There is no day after DateTime.MaxValue.Date to advance to, so AddDays(1) would throw + // for a user-suppliable filter value at the ceiling. Clamp to MaxValue, which is still a + // safe exclusive upper bound (nothing sorts after it). + if (to >= DateTime.MaxValue.Date) + { + return DateTime.MaxValue; + } + + return to.AddDays(1); + } +} diff --git a/web/Program.cs b/web/Program.cs index 8f87dc1e7..0b80fcd7c 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 => { @@ -494,6 +498,10 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db // Auth/session run after the SPA shell block so built Vue assets skip them, but // still before health checks, Hangfire, and controllers, which need an authenticated user. app.UseAuthentication(); + // After authentication so download rate-limit buckets can key on the logged-in user + // (kinder to shared campus NAT), but before authorization so abusive traffic is + // limited without paying the authorization cost first. + app.UseRateLimiter(); app.UseAuthorization(); app.UseCookiePolicy(); app.UseSession(); diff --git a/web/Services/HtmlSanitizerService.cs b/web/Services/HtmlSanitizerService.cs index 7a9de7841..0001f47ec 100644 --- a/web/Services/HtmlSanitizerService.cs +++ b/web/Services/HtmlSanitizerService.cs @@ -9,12 +9,19 @@ namespace Viper.Services public class HtmlSanitizerService : IHtmlSanitizerService { private readonly HtmlSanitizer _sanitizer; + private readonly HtmlSanitizer _diffSanitizer; public HtmlSanitizerService() { - _sanitizer = new HtmlSanitizer(); + _sanitizer = BuildSanitizer(allowDiffMarkers: false); + _diffSanitizer = BuildSanitizer(allowDiffMarkers: true); + } + + private static HtmlSanitizer BuildSanitizer(bool allowDiffMarkers) + { + var sanitizer = new HtmlSanitizer(); - _sanitizer.AllowedTags.Clear(); + sanitizer.AllowedTags.Clear(); foreach (var tag in new[] { "p", "br", "hr", "strong", "b", "em", "i", "u", "s", "strike", @@ -26,10 +33,20 @@ public HtmlSanitizerService() "sub", "sup", "small", "abbr", "cite", "q" }) { - _sanitizer.AllowedTags.Add(tag); + sanitizer.AllowedTags.Add(tag); + } + + if (allowDiffMarkers) + { + // htmldiff.net wraps changes in / (classes diffins/diffdel/diffmod). + // Allowing just these two tags lets a diff be re-sanitized to balance the library's + // markup and strip anything off-policy while keeping the change markers. The class + // attribute is already allowed below, so the diff* classes survive. + sanitizer.AllowedTags.Add("ins"); + sanitizer.AllowedTags.Add("del"); } - _sanitizer.AllowedAttributes.Clear(); + sanitizer.AllowedAttributes.Clear(); foreach (var attr in new[] { "href", "src", "alt", "title", "class", "id", "name", @@ -38,7 +55,7 @@ public HtmlSanitizerService() "style" }) { - _sanitizer.AllowedAttributes.Add(attr); + sanitizer.AllowedAttributes.Add(attr); } // Curated CSS property allowlist. Mirrors legacy antisamy-cms.xml plus font-size, which @@ -46,7 +63,7 @@ public HtmlSanitizerService() // dangerous CSS value constructs are blocked by Ganss.Xss regardless of this list. // Shorthand properties (text-decoration, list-style) get expanded to longhands by // AngleSharp; both forms must be allowed or the entire declaration is dropped. - _sanitizer.AllowedCssProperties.Clear(); + sanitizer.AllowedCssProperties.Clear(); foreach (var prop in new[] { "color", "font-family", "font-size", "font-style", "font-weight", @@ -60,25 +77,25 @@ public HtmlSanitizerService() "list-style", "list-style-type", "list-style-position", "list-style-image" }) { - _sanitizer.AllowedCssProperties.Add(prop); + sanitizer.AllowedCssProperties.Add(prop); } - _sanitizer.AllowedSchemes.Clear(); - _sanitizer.AllowedSchemes.Add("http"); - _sanitizer.AllowedSchemes.Add("https"); - _sanitizer.AllowedSchemes.Add("mailto"); - _sanitizer.AllowedSchemes.Add("tel"); + sanitizer.AllowedSchemes.Clear(); + sanitizer.AllowedSchemes.Add("http"); + sanitizer.AllowedSchemes.Add("https"); + sanitizer.AllowedSchemes.Add("mailto"); + sanitizer.AllowedSchemes.Add("tel"); // Block all CSS at-rules (@import, @font-face, etc.). At-rules are not valid inside // an inline style attribute, but clearing the set is defense in depth against parser // edge cases. - _sanitizer.AllowedAtRules.Clear(); + sanitizer.AllowedAtRules.Clear(); // Block data: URIs in any URL (image XSS vector) on top of the scheme allowlist. // Also lock to relative URLs only — matches the legacy antisamy-cms.xml // onsiteURL regex and prevents editor-authored content from embedding third-party // tracking beacons or any absolute-URL image (same-origin absolutes included). - _sanitizer.FilterUrl += (sender, args) => + sanitizer.FilterUrl += (_, args) => { if (args.OriginalUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) { @@ -92,6 +109,8 @@ public HtmlSanitizerService() args.SanitizedUrl = null; } }; + + return sanitizer; } private static bool IsOffSiteUrl(string url) @@ -103,5 +122,7 @@ private static bool IsOffSiteUrl(string url) } public string Sanitize(string html) => _sanitizer.Sanitize(html); + + public string SanitizeDiff(string html) => _diffSanitizer.Sanitize(html); } } diff --git a/web/Services/IHtmlSanitizerService.cs b/web/Services/IHtmlSanitizerService.cs index ab451e0a3..0225b525b 100644 --- a/web/Services/IHtmlSanitizerService.cs +++ b/web/Services/IHtmlSanitizerService.cs @@ -3,5 +3,12 @@ namespace Viper.Services public interface IHtmlSanitizerService { string Sanitize(string html); + + /// + /// Sanitize generated diff markup. Same policy as but additionally + /// permits the <ins>/<del> markers htmldiff.net wraps changes in, so a diff can be + /// re-parsed (balancing the library's malformed tags) and stripped to policy before render. + /// + string SanitizeDiff(string html); } } diff --git a/web/Viper.csproj b/web/Viper.csproj index 4ccb2a6ac..eafc1ba5a 100644 --- a/web/Viper.csproj +++ b/web/Viper.csproj @@ -54,6 +54,7 @@ + diff --git a/web/appsettings.json b/web/appsettings.json index 39f5bbfe9..804b7e799 100644 --- a/web/appsettings.json +++ b/web/appsettings.json @@ -39,5 +39,11 @@ "Hangfire": { // Hangfire's tables live in ConnectionStrings:VIPER. "Enabled": true + }, + "Cms": { + // Gates the cms:trash-purge scheduled job. Keep false until the legacy VIPER 1 30-day + // trash purge is retired, so the two never purge in parallel. Flip to true (via SSM in + // deployed envs) at cutover to let the daily job permanently delete files trashed > 30 days ago. + "TrashPurgeEnabled": false } } From 8b2cee3d63a85f1bc59da91105414bd930b7c95f Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 2 Jul 2026 21:03:17 -0700 Subject: [PATCH 2/4] VPR-59 chore: disable Biome in CodeRabbit reviews Its strict JSON parser cannot read appsettings*.json (ASP.NET parses those as JSON-with-comments by design) and warned on every review touching them; CI already covers its lint surface. --- .coderabbit.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 841236ab5..bbacc6de1 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -46,6 +46,11 @@ reviews: languagetool: enabled: true level: default + # Biome's strict JSON parser can't read appsettings*.json (ASP.NET parses those as + # JSON-with-comments by design), so it warned on every review touching them. Its lint + # coverage is already provided by CI (oxlint/oxfmt, JSCPD, Fallow, ReSharper, CodeQL). + biome: + enabled: false chat: auto_reply: true From 93e78276c4cf44e31b872598b06a62e2a5c04cbf Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 16 Jul 2026 21:57:11 -0700 Subject: [PATCH 3/4] VPR-59 fix(cms): resolve legacy webroot from per-environment config - CMS:LegacyWebrootPath now ships in appsettings.{Environment}.json (dev: C:\Sites\https\VIPER; TEST/PROD: S:\webroot\https\viper, matching the legacy deploy configs) instead of a hardcoded dev-only fallback - unset in any environment fails fast with the existing not-configured error --- web/Areas/CMS/Services/CmsFileImportService.cs | 9 +++------ web/appsettings.Development.json | 3 +++ web/appsettings.Production.json | 3 +++ web/appsettings.Test.json | 3 +++ 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/web/Areas/CMS/Services/CmsFileImportService.cs b/web/Areas/CMS/Services/CmsFileImportService.cs index 300f61d15..020786e20 100644 --- a/web/Areas/CMS/Services/CmsFileImportService.cs +++ b/web/Areas/CMS/Services/CmsFileImportService.cs @@ -43,12 +43,9 @@ public CmsFileImportService(VIPERContext context, ICmsFileStorageService storage _audit = audit; _userHelper = userHelper; _logger = logger; - // The dev default is a Windows path; on any other OS leave it unset so the - // import endpoints fail fast with the explicit not-configured error instead - // of probing a path that cannot exist. - _legacyWebroot = configuration["CMS:LegacyWebrootPath"] - ?? (HttpHelper.Environment?.EnvironmentName == "Development" && OperatingSystem.IsWindows() - ? @"C:\Sites\https\VIPER" : null); + // Set per environment in appsettings.{Environment}.json; when unset the import + // endpoints fail fast with the explicit not-configured error. + _legacyWebroot = configuration["CMS:LegacyWebrootPath"]; } public async Task> ImportFilesAsync(CmsFileImportRequest request, CancellationToken ct = default) diff --git a/web/appsettings.Development.json b/web/appsettings.Development.json index 21bc6cff8..c668d17a0 100644 --- a/web/appsettings.Development.json +++ b/web/appsettings.Development.json @@ -22,6 +22,9 @@ "Cas": { "CasBaseUrl": "https://ssodev.ucdavis.edu/cas/" }, + "CMS": { + "LegacyWebrootPath": "C:\\Sites\\https\\VIPER" + }, "Hangfire": { "AutoSchedule": false }, diff --git a/web/appsettings.Production.json b/web/appsettings.Production.json index 3ac032657..624aafafb 100644 --- a/web/appsettings.Production.json +++ b/web/appsettings.Production.json @@ -18,6 +18,9 @@ "SIS": "", "VIPER": "" }, + "CMS": { + "LegacyWebrootPath": "S:\\webroot\\https\\viper" + }, "EmailSettings": { "SmtpHost": "ucdavis-edu.mail.protection.outlook.com", "SmtpPort": 25, diff --git a/web/appsettings.Test.json b/web/appsettings.Test.json index 1eb01c026..a26b0902b 100644 --- a/web/appsettings.Test.json +++ b/web/appsettings.Test.json @@ -21,6 +21,9 @@ "Cas": { "CasBaseUrl": "https://ssodev.ucdavis.edu/cas/" }, + "CMS": { + "LegacyWebrootPath": "S:\\webroot\\https\\viper" + }, "EmailSettings": { "SmtpHost": "ucdavis-edu.mail.protection.outlook.com", "SmtpPort": 25, From 6eba6215d7e36ac88b5078ed81353f4d2a076673 Mon Sep 17 00:00:00 2001 From: Rex Lorenzo Date: Thu, 16 Jul 2026 21:59:08 -0700 Subject: [PATCH 4/4] VPR-59 feat(cms): tolerate viper/-prefixed import paths like legacy The legacy import tool stripped a leading "viper" segment from pasted paths (case/separator insensitive). ResolveSource now does the same so those lines resolve, and the recorded OldUrl stays webroot-relative. --- test/CMS/CmsFileImportServiceTests.cs | 37 +++++++++++++++++++ .../CMS/Services/CmsFileImportService.cs | 7 ++++ 2 files changed, 44 insertions(+) diff --git a/test/CMS/CmsFileImportServiceTests.cs b/test/CMS/CmsFileImportServiceTests.cs index bbc16a42c..7c34681c6 100644 --- a/test/CMS/CmsFileImportServiceTests.cs +++ b/test/CMS/CmsFileImportServiceTests.cs @@ -97,6 +97,25 @@ public async Task Import_MovesFileAndRecordsOldUrl() _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.ImportFile, Arg.Any()); } + [Fact] + public async Task Import_LeadingViperSegment_IsStrippedLikeLegacy() + { + var source = CreateWebrootFile(@"cats\docs\manual.pdf"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "viper/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.False(File.Exists(source)); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal("/cats/docs/manual.pdf", saved.OldUrl); + } + [Fact] public async Task Import_DefaultPermission_AddsSvmSecureFolder() { @@ -175,6 +194,24 @@ public async Task Import_WithEncrypt_EncryptsBeforeMove() #region Preview + [Fact] + public async Task Preview_LeadingViperSegment_StripsRegardlessOfCaseAndSeparators() + { + var source = CreateWebrootFile(@"cats\docs\manual.pdf"); + var request = new CmsFileImportRequest + { + FilePaths = new List { @"\VIPER\cats\docs\manual.pdf" }, + Folder = "cats" + }; + + var results = await _service.PreviewImportAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.True(result.CanImport, result.Message); + Assert.Equal("/cats/docs/manual.pdf", result.OldUrl); + Assert.True(File.Exists(source)); + } + [Fact] public async Task Preview_ValidFile_ReportsNamesWithoutMoving() { diff --git a/web/Areas/CMS/Services/CmsFileImportService.cs b/web/Areas/CMS/Services/CmsFileImportService.cs index 020786e20..cc3eb7f82 100644 --- a/web/Areas/CMS/Services/CmsFileImportService.cs +++ b/web/Areas/CMS/Services/CmsFileImportService.cs @@ -156,6 +156,13 @@ private SourceResolution ResolveSource(string rawPath) // every OS. Path APIs only honor the host separator, so on Linux a "..\" segment would // otherwise be read as part of a filename and slip past the outside-webroot check. var segments = rawPath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries); + // The legacy import tolerated lines pasted with the site prefix (e.g. "viper/cats/x.pdf") + // by stripping it; drop a leading "viper" segment so those lines resolve, and so the + // recorded OldUrl stays webroot-relative like the legacy oldURL. + if (segments.Length > 0 && string.Equals(segments[0], "viper", StringComparison.OrdinalIgnoreCase)) + { + segments = segments[1..]; + } string relative = string.Join('/', segments); string sourcePath; string fileName;