diff --git a/VueApp/src/CMS/pages/BulkEncrypt.vue b/VueApp/src/CMS/pages/BulkEncrypt.vue new file mode 100644 index 000000000..5224965a2 --- /dev/null +++ b/VueApp/src/CMS/pages/BulkEncrypt.vue @@ -0,0 +1,242 @@ + + + diff --git a/VueApp/src/CMS/pages/CmsHome.vue b/VueApp/src/CMS/pages/CmsHome.vue index 58c9aa4aa..b5bd1ef22 100644 --- a/VueApp/src/CMS/pages/CmsHome.vue +++ b/VueApp/src/CMS/pages/CmsHome.vue @@ -63,6 +63,8 @@ const sections: Section[] = [ actions: [ { label: "Manage Files", to: { name: "CmsFiles" } }, { label: "Audit Log", to: { name: "CmsFileAudit" } }, + { label: "Import", to: { name: "CmsFileImport" } }, + { label: "Bulk Encrypt", to: { name: "CmsBulkEncrypt" } }, ], }, { diff --git a/VueApp/src/CMS/pages/FileAuditLog.vue b/VueApp/src/CMS/pages/FileAuditLog.vue index 19c645f36..e1e6c1897 100644 --- a/VueApp/src/CMS/pages/FileAuditLog.vue +++ b/VueApp/src/CMS/pages/FileAuditLog.vue @@ -121,11 +121,12 @@ diff --git a/VueApp/src/CMS/router/routes.ts b/VueApp/src/CMS/router/routes.ts index af1691deb..b5b7da5dd 100644 --- a/VueApp/src/CMS/router/routes.ts +++ b/VueApp/src/CMS/router/routes.ts @@ -33,6 +33,18 @@ const routes = [ meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.AllFiles"] }, component: () => import("@/CMS/pages/FileAuditLog.vue"), }, + { + path: "/CMS/ManageFiles/Import", + name: "CmsFileImport", + meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.AllFiles"] }, + component: () => import("@/CMS/pages/ImportFiles.vue"), + }, + { + path: "/CMS/ManageFiles/BulkEncrypt", + name: "CmsBulkEncrypt", + meta: { layout: ViperLayout, permissions: ["SVMSecure.CMS.AllFiles"] }, + component: () => import("@/CMS/pages/BulkEncrypt.vue"), + }, { path: "/CMS/ManageContentBlocks", name: "CmsContentBlocks", diff --git a/test/CMS/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/CmsFileImportServiceTests.cs b/test/CMS/CmsFileImportServiceTests.cs new file mode 100644 index 000000000..84cf8a721 --- /dev/null +++ b/test/CMS/CmsFileImportServiceTests.cs @@ -0,0 +1,208 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using NSubstitute; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Areas.CMS.Services; +using Viper.Classes.SQLContext; + +namespace Viper.test.CMS; + +/// +/// Tests for CmsFileImportService: importing from the legacy webroot (source containment, +/// move semantics, oldURL tracking, default permission) and bulk encryption. +/// +public sealed class CmsFileImportServiceTests : IDisposable +{ + private readonly string _webroot; + private readonly VIPERContext _context; + private readonly ICmsFileStorageService _storage; + private readonly ICmsFileEncryptionService _encryption; + private readonly ICmsFileAuditService _audit; + private readonly CmsFileImportService _service; + + public CmsFileImportServiceTests() + { + _webroot = Path.Join(Path.GetTempPath(), "ViperCmsImportTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Join(_webroot, "cats", "docs")); + + _context = new VIPERContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("VIPER_" + Guid.NewGuid()).Options); + + _storage = Substitute.For(); + _storage.IsValidFolder(Arg.Any()).Returns(true); + _storage.MoveIntoPlace(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + // emulate the real move so source-removal logic can be tested + File.Delete(callInfo.ArgAt(0)); + return Path.Join(@"C:\FakeRoot", callInfo.ArgAt(1), Path.GetFileName(callInfo.ArgAt(2))); + }); + + _encryption = Substitute.For(); + _encryption.GenerateKeyForDb().Returns("encrypted-db-key"); + _audit = Substitute.For(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["CMS:LegacyWebrootPath"] = _webroot }) + .Build(); + + _service = new CmsFileImportService(_context, _storage, _encryption, _audit, + Substitute.For(), configuration); + } + + public void Dispose() + { + _context.Dispose(); + if (Directory.Exists(_webroot)) + { + Directory.Delete(_webroot, recursive: true); + } + } + + private string CreateWebrootFile(string relativePath, string contents = "legacy file") + { + var path = Path.Join(_webroot, relativePath); + File.WriteAllText(path, contents); + return path; + } + + [Fact] + public async Task Import_MovesFileAndRecordsOldUrl() + { + var source = CreateWebrootFile(@"cats\docs\manual.pdf"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "/cats/docs/manual.pdf" }, + Folder = "cats" + }; + + var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.True(result.Success, result.Message); + Assert.Equal("cats-manual.pdf", result.FriendlyName); + Assert.False(File.Exists(source)); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.Equal("/cats/docs/manual.pdf", saved.OldUrl); + Assert.Equal("Automatically imported from Viper", saved.Description); + _audit.Received(1).Audit(Arg.Any(), CmsFileAuditActions.ImportFile, Arg.Any()); + } + + [Fact] + public async Task Import_DefaultPermission_AddsSvmSecureFolder() + { + CreateWebrootFile(@"cats\docs\manual.pdf"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "cats/docs/manual.pdf" }, + Folder = @"cats\docs", + UseDefaultPermission = true, + Permissions = new List { "SVMSecure.Extra" } + }; + + await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken); + + var saved = await _context.Files.Include(f => f.FileToPermissions).SingleAsync(TestContext.Current.CancellationToken); + var permissions = saved.FileToPermissions.Select(p => p.Permission).ToList(); + Assert.Equal(2, permissions.Count); + Assert.Contains("SVMSecure.Extra", permissions); + Assert.Contains("SVMSecure.cats", permissions); + } + + [Fact] + public async Task Import_TraversalOutsideWebroot_Fails() + { + var request = new CmsFileImportRequest + { + FilePaths = new List { @"..\..\Windows\system.ini" }, + Folder = "cats" + }; + + var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken); + + var result = Assert.Single(results); + Assert.False(result.Success); + Assert.Contains("outside", result.Message); + Assert.Empty(await _context.Files.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Import_MissingAndDisallowedFiles_ReportPerFileErrors() + { + CreateWebrootFile(@"cats\evil.bat", "nope"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "cats/missing.pdf", "cats/evil.bat" }, + Folder = "cats" + }; + + var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(2, results.Count); + Assert.All(results, r => Assert.False(r.Success)); + Assert.Contains("not found", results[0].Message); + Assert.Contains("not allowed", results[1].Message); + } + + [Fact] + public async Task Import_WithEncrypt_EncryptsBeforeMove() + { + CreateWebrootFile(@"cats\docs\secret.pdf"); + var request = new CmsFileImportRequest + { + FilePaths = new List { "cats/docs/secret.pdf" }, + Folder = "cats", + Encrypt = true + }; + + var results = await _service.ImportFilesAsync(request, TestContext.Current.CancellationToken); + + Assert.True(results[0].Success); + _encryption.Received(1).EncryptFileInPlace(Arg.Any(), "encrypted-db-key"); + var saved = await _context.Files.SingleAsync(TestContext.Current.CancellationToken); + Assert.True(saved.Encrypted); + Assert.Equal("encrypted-db-key", saved.Key); + } + + [Fact] + public async Task BulkEncrypt_EncryptsUnencryptedAndSkipsOthers() + { + var plain = new Viper.Models.VIPER.File + { + FileGuid = Guid.NewGuid(), + FilePath = @"C:\FakeRoot\cats\plain.pdf", + Folder = "cats", + FriendlyName = "cats-plain.pdf", + Description = "", + ModifiedBy = "test", + ModifiedOn = DateTime.Now + }; + var already = new Viper.Models.VIPER.File + { + FileGuid = Guid.NewGuid(), + FilePath = @"C:\FakeRoot\cats\already.pdf", + Folder = "cats", + FriendlyName = "cats-already.pdf", + Description = "", + Encrypted = true, + Key = "existing-key", + ModifiedBy = "test", + ModifiedOn = DateTime.Now + }; + _context.Files.AddRange(plain, already); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + _storage.ManagedFileExists(plain.FilePath).Returns(true); + + var results = await _service.BulkEncryptAsync( + new List { plain.FileGuid, already.FileGuid, Guid.NewGuid() }, TestContext.Current.CancellationToken); + + Assert.Equal(3, results.Count); + Assert.True(results[0].Success); + Assert.False(results[1].Success); + Assert.Contains("Already encrypted", results[1].Message); + Assert.False(results[2].Success); + _encryption.Received(1).EncryptFileInPlace(plain.FilePath, "encrypted-db-key"); + var saved = await _context.Files.SingleAsync(f => f.FileGuid == plain.FileGuid, TestContext.Current.CancellationToken); + Assert.True(saved.Encrypted); + } +} diff --git a/test/CMS/CmsUserPhotoServiceTests.cs b/test/CMS/CmsUserPhotoServiceTests.cs new file mode 100644 index 000000000..e3efb7fa0 --- /dev/null +++ b/test/CMS/CmsUserPhotoServiceTests.cs @@ -0,0 +1,162 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Viper.Areas.CMS.Services; +using Viper.Areas.Students.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.CMS; + +/// +/// Tests for CmsUserPhotoService: AAUD id resolution, alternate ProfilePhotos lookup with +/// traversal protection, and delegation to the Students photo pipeline. +/// +public sealed class CmsUserPhotoServiceTests : IDisposable +{ + private readonly string _profilePhotoRoot; + private readonly AAUDContext _aaudContext; + private readonly IPhotoService _photoService; + private readonly CmsUserPhotoService _service; + + private static readonly byte[] IdCardPhotoBytes = { 1, 1, 1 }; + private static readonly byte[] AltPhotoBytes = { 2, 2, 2 }; + + public CmsUserPhotoServiceTests() + { + _profilePhotoRoot = Path.Join(Path.GetTempPath(), "ViperCmsPhotoTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_profilePhotoRoot); + + _aaudContext = new AAUDContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase("AAUD_" + Guid.NewGuid()).Options); + + _photoService = Substitute.For(); + _photoService.GetStudentPhotoAsync(Arg.Any()).Returns(Task.FromResult(IdCardPhotoBytes)); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["CMS:ProfilePhotoPath"] = _profilePhotoRoot }) + .Build(); + + _service = new CmsUserPhotoService(_aaudContext, _photoService, configuration, + Substitute.For>()); + } + + public void Dispose() + { + _aaudContext.Dispose(); + if (Directory.Exists(_profilePhotoRoot)) + { + Directory.Delete(_profilePhotoRoot, recursive: true); + } + } + + private void SeedUser(string loginId = "jdoe", string mailId = "jdoe", string iamId = "1000999") + { + _aaudContext.AaudUsers.Add(new AaudUser + { + AaudUserId = 1, + ClientId = "test-client", + LoginId = loginId, + MailId = mailId, + IamId = iamId, + Current = 1, + LastName = "Doe", + FirstName = "Jane", + DisplayLastName = "Doe", + DisplayFirstName = "Jane", + DisplayFullName = "Doe, Jane", + MothraId = "m1", + SpridenId = null, + Pidm = null, + EmployeeId = null, + VmacsId = null, + VmcasId = null, + UnexId = null + }); + _aaudContext.SaveChanges(); + } + + [Fact] + public async Task GetUserPhoto_ByMailId_DelegatesToStudentPipeline() + { + var photo = await _service.GetUserPhotoAsync("jdoe", null, null, preferAltPhoto: false, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo); + await _photoService.Received(1).GetStudentPhotoAsync("jdoe"); + } + + [Fact] + public async Task GetUserPhoto_ByLoginId_ResolvesMailIdThroughAaud() + { + SeedUser(loginId: "jdoe", mailId: "janedoe"); + + await _service.GetUserPhotoAsync(null, "jdoe", null, preferAltPhoto: false, + TestContext.Current.CancellationToken); + + await _photoService.Received(1).GetStudentPhotoAsync("janedoe"); + } + + [Fact] + public async Task GetUserPhoto_UnknownLoginId_FallsBackToDefaultPipeline() + { + var photo = await _service.GetUserPhotoAsync(null, "nosuchuser", null, preferAltPhoto: false, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo); + await _photoService.Received(1).GetStudentPhotoAsync(string.Empty); + } + + [Fact] + public async Task GetUserPhoto_PreferAltPhoto_ServesProfilePhoto() + { + SeedUser(iamId: "1000999"); + await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes, + TestContext.Current.CancellationToken); + + var photo = await _service.GetUserPhotoAsync(null, "jdoe", null, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(AltPhotoBytes, photo); + await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any()); + } + + [Fact] + public async Task GetUserPhoto_ByMailIdPreferAltPhoto_ResolvesIamIdAndServesProfilePhoto() + { + SeedUser(mailId: "jdoe", iamId: "1000999"); + await File.WriteAllBytesAsync(Path.Join(_profilePhotoRoot, "1000999.jpg"), AltPhotoBytes, + TestContext.Current.CancellationToken); + + var photo = await _service.GetUserPhotoAsync("jdoe", null, null, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(AltPhotoBytes, photo); + await _photoService.DidNotReceive().GetStudentPhotoAsync(Arg.Any()); + } + + [Fact] + public async Task GetUserPhoto_PreferAltPhoto_MissingAltFallsBackToIdCard() + { + SeedUser(mailId: "janedoe", iamId: "1000999"); + + var photo = await _service.GetUserPhotoAsync(null, "jdoe", null, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo); + await _photoService.Received(1).GetStudentPhotoAsync("janedoe"); + } + + [Theory] + [InlineData("../1000999")] + [InlineData("..\\secrets")] + [InlineData("a/b")] + public async Task GetUserPhoto_TraversalShapedIamId_IgnoresAltPhoto(string iamId) + { + var photo = await _service.GetUserPhotoAsync(null, null, iamId, preferAltPhoto: true, + TestContext.Current.CancellationToken); + + Assert.Equal(IdCardPhotoBytes, photo); + } +} diff --git a/web/Areas/CMS/Controllers/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 index a521b25a6..8a3cc70d5 100644 --- a/web/Areas/CMS/Controllers/CMSFilesController.cs +++ b/web/Areas/CMS/Controllers/CMSFilesController.cs @@ -24,17 +24,19 @@ public class CMSFilesController : ApiController private readonly ICmsFileService _fileService; private readonly ICmsFileStorageService _storage; private readonly ICmsFileAuditService _auditService; + private readonly ICmsFileImportService _importService; private readonly RAPSContext _rapsContext; private readonly ILogger _logger; private readonly IUserHelper _userHelper; public CMSFilesController(ICmsFileService fileService, ICmsFileStorageService storage, - ICmsFileAuditService auditService, RAPSContext rapsContext, ILogger logger, - IUserHelper userHelper) + ICmsFileAuditService auditService, ICmsFileImportService importService, RAPSContext rapsContext, + ILogger logger, IUserHelper userHelper) { _fileService = fileService; _storage = storage; _auditService = auditService; + _importService = importService; _rapsContext = rapsContext; _logger = logger; _userHelper = userHelper; @@ -193,6 +195,42 @@ public async Task RestoreFile(Guid fileGuid, CancellationToken ct return await _fileService.RestoreFileAsync(fileGuid, ct) ? Ok() : NotFound(); } + // POST /api/cms/files/import — move files from the legacy VIPER webroot into the store + [HttpPost("import")] + public async Task>> ImportFiles(CmsFileImportRequest request, + CancellationToken ct = default) + { + if (request.FilePaths.Count == 0) + { + return BadRequest("At least one file path is required."); + } + + try + { + return await _importService.ImportFilesAsync(request, ct); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + // POST /api/cms/files/bulk-encrypt + [HttpPost("bulk-encrypt")] + public async Task>> BulkEncrypt(List fileGuids, + CancellationToken ct = default) + { + if (fileGuids.Count == 0) + { + return BadRequest("At least one file is required."); + } + return await _importService.BulkEncryptAsync(fileGuids, ct); + } + private void LogFileConflict(string operation, string? folder, string? fileName, Exception ex) { _logger.LogWarning(ex, "CMS file {Operation} conflict folder={Folder} fileName={FileName}", diff --git a/web/Areas/CMS/Controllers/CMSUserPhotoController.cs b/web/Areas/CMS/Controllers/CMSUserPhotoController.cs new file mode 100644 index 000000000..c299e1d1e --- /dev/null +++ b/web/Areas/CMS/Controllers/CMSUserPhotoController.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.CMS.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.CMS.Controllers +{ + /// + /// User photos for authenticated VIPER pages (legacy userPhoto.cfc). Photos are visible + /// to any logged-in user, matching legacy behavior; gallery-style browsing with its own + /// permissions lives in the Students area. + /// + [Route("/api/cms/photos")] + [Permission(Allow = "SVMSecure")] + public class CMSUserPhotoController : ApiController + { + private const int CacheSeconds = 3600; + + private readonly ICmsUserPhotoService _photoService; + + public CMSUserPhotoController(ICmsUserPhotoService photoService) + { + _photoService = photoService; + } + + // GET /api/cms/photos/by-mail/{mailId}?altPhoto=true|false + [HttpGet("by-mail/{mailId}")] + public async Task GetByMailId(string mailId, bool altPhoto = false, CancellationToken ct = default) + { + return await ServePhoto(mailId: mailId, loginId: null, iamId: null, altPhoto, ct); + } + + // GET /api/cms/photos/by-login/{loginId}?altPhoto=true|false + [HttpGet("by-login/{loginId}")] + public async Task GetByLoginId(string loginId, bool altPhoto = false, CancellationToken ct = default) + { + return await ServePhoto(mailId: null, loginId: loginId, iamId: null, altPhoto, ct); + } + + // GET /api/cms/photos/by-iam/{iamId}?altPhoto=true|false + [HttpGet("by-iam/{iamId}")] + public async Task GetByIamId(string iamId, bool altPhoto = false, CancellationToken ct = default) + { + return await ServePhoto(mailId: null, loginId: null, iamId: iamId, altPhoto, ct); + } + + private async Task ServePhoto(string? mailId, string? loginId, string? iamId, + bool altPhoto, CancellationToken ct) + { + var photo = await _photoService.GetUserPhotoAsync(mailId, loginId, iamId, altPhoto, ct); + // Photos change rarely; let browsers cache for an hour (legacy used short-lived + // cache headers with long stale-while-revalidate). + Response.Headers.CacheControl = $"private, max-age={CacheSeconds}"; + return File(photo, "image/jpeg"); + } + } +} diff --git a/web/Areas/CMS/Models/DTOs/CmsFileImportDtos.cs b/web/Areas/CMS/Models/DTOs/CmsFileImportDtos.cs new file mode 100644 index 000000000..09c9fe505 --- /dev/null +++ b/web/Areas/CMS/Models/DTOs/CmsFileImportDtos.cs @@ -0,0 +1,43 @@ +using System.ComponentModel.DataAnnotations; + +namespace Viper.Areas.CMS.Models.DTOs +{ + /// + /// Import existing files from the legacy VIPER webroot into the managed file store. + /// Paths are relative to the configured legacy webroot. + /// + public class CmsFileImportRequest + { + [Required] + public List FilePaths { get; set; } = new(); + + [Required] + public string Folder { get; set; } = string.Empty; + + public List Permissions { get; set; } = new(); + + /// Add the folder's default permission (SVMSecure.{folder}) to each file. + public bool? UseDefaultPermission { get; set; } + + public bool? AllowPublicAccess { get; set; } + + public bool? Encrypt { get; set; } + } + + public class CmsFileImportResult + { + public string FilePath { get; set; } = string.Empty; + public bool Success { get; set; } + public string? Message { get; set; } + public Guid? FileGuid { get; set; } + public string? FriendlyName { get; set; } + } + + public class CmsBulkEncryptResult + { + public Guid FileGuid { get; set; } + public string? FriendlyName { get; set; } + public bool Success { get; set; } + public string? Message { get; set; } + } +} diff --git a/web/Areas/CMS/Services/CmsDownloadRateLimiting.cs b/web/Areas/CMS/Services/CmsDownloadRateLimiting.cs new file mode 100644 index 000000000..c3a8cf90d --- /dev/null +++ b/web/Areas/CMS/Services/CmsDownloadRateLimiting.cs @@ -0,0 +1,118 @@ +using System.Globalization; +using System.Threading.RateLimiting; +using Viper.Classes.Utilities; + +namespace Viper.Areas.CMS.Services +{ + /// + /// Rate limiting for the CMS download endpoint (/CMS/Files). ZIP requests assemble + /// multi-file archives in memory, so they get a strict token bucket; single-file + /// downloads get a generous sliding window that allows image-heavy pages but stops + /// sustained scraping. Buckets are per login id when authenticated, per client IP + /// otherwise (forwarded headers are already resolved for the Cloudflare/F5 chain, + /// so RemoteIpAddress is the real client on TEST/PROD). + /// + public static class CmsDownloadRateLimiting + { + public const string PolicyName = "CmsDownloads"; + + private const string ZipPrefix = "zip|"; + private const string FilePrefix = "file|"; + + // Defaults; override under CMS:DownloadRateLimit in appsettings. + private const int DefaultFilePermitsPerMinute = 100; + private const int DefaultZipTokenLimit = 5; + private const int DefaultZipTokensPerMinute = 1; + + public static IServiceCollection AddCmsDownloadRateLimiting(this IServiceCollection services, IConfiguration configuration) + { + int filePermits = configuration.GetValue("CMS:DownloadRateLimit:FilePermitsPerMinute", DefaultFilePermitsPerMinute); + int zipTokenLimit = configuration.GetValue("CMS:DownloadRateLimit:ZipTokenLimit", DefaultZipTokenLimit); + int zipTokensPerMinute = configuration.GetValue("CMS:DownloadRateLimit:ZipTokensPerMinute", DefaultZipTokensPerMinute); + + // Misconfigured (zero/negative) values would make the limiter option + // constructors throw on the first request, so fall back to defaults. + if (filePermits <= 0) filePermits = DefaultFilePermitsPerMinute; + if (zipTokenLimit <= 0) zipTokenLimit = DefaultZipTokenLimit; + if (zipTokensPerMinute <= 0) zipTokensPerMinute = DefaultZipTokensPerMinute; + + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = async (context, ct) => + { + var httpContext = context.HttpContext; + + // Visibility for tuning the limits on TEST/PROD. Partition key is the + // CAS login or proxy-resolved client IP, not raw user input. + httpContext.RequestServices + .GetRequiredService() + .CreateLogger(nameof(CmsDownloadRateLimiting)) + .LogInformation("Download rate limit hit for {PartitionKey} on {Path}", + GetPartitionKey(httpContext), + LogSanitizer.SanitizeString(httpContext.Request.Path)); + + // A response already in flight can't take headers or a new body. + if (httpContext.Response.HasStarted) + { + return; + } + + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + httpContext.Response.Headers.RetryAfter = + ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture); + } + httpContext.Response.ContentType = "text/plain"; + await httpContext.Response.WriteAsync( + "Too many download requests. Please wait a moment and try again.", ct); + }; + + options.AddPolicy(PolicyName, httpContext => + { + var key = GetPartitionKey(httpContext); + return key.StartsWith(ZipPrefix, StringComparison.Ordinal) + ? RateLimitPartition.GetTokenBucketLimiter(key, _ => new TokenBucketRateLimiterOptions + { + TokenLimit = zipTokenLimit, + ReplenishmentPeriod = TimeSpan.FromMinutes(1), + TokensPerPeriod = zipTokensPerMinute, + AutoReplenishment = true, + QueueLimit = 0 + }) + : RateLimitPartition.GetSlidingWindowLimiter(key, _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = filePermits, + Window = TimeSpan.FromMinutes(1), + SegmentsPerWindow = 6, + QueueLimit = 0 + }); + }); + }); + + return services; + } + + /// + /// Partition key: request type (zip vs single file, matching CMSController.Files' + /// dispatch on the ids parameter) plus the caller's login id, or client IP for + /// anonymous/public requests. + /// + public static string GetPartitionKey(HttpContext httpContext) + { + string prefix = IsZipRequest(httpContext) ? ZipPrefix : FilePrefix; + string? login = httpContext.User.Identity?.IsAuthenticated == true + ? httpContext.User.Identity.Name + : null; + string caller = !string.IsNullOrEmpty(login) + ? login + : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return prefix + caller; + } + + public static bool IsZipRequest(HttpContext httpContext) + { + return !string.IsNullOrEmpty(httpContext.Request.Query["ids"]); + } + } +} diff --git a/web/Areas/CMS/Services/CmsFileImportService.cs b/web/Areas/CMS/Services/CmsFileImportService.cs new file mode 100644 index 000000000..ff7b5abd6 --- /dev/null +++ b/web/Areas/CMS/Services/CmsFileImportService.cs @@ -0,0 +1,287 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.CMS.Constants; +using Viper.Areas.CMS.Models.DTOs; +using Viper.Classes.SQLContext; +using File = Viper.Models.VIPER.File; + +namespace Viper.Areas.CMS.Services +{ + public interface ICmsFileImportService + { + Task> ImportFilesAsync(CmsFileImportRequest request, CancellationToken ct = default); + + Task> BulkEncryptAsync(List fileGuids, CancellationToken ct = default); + } + + /// + /// Bulk file tools mirroring the legacy CMS: import loose files from the legacy VIPER + /// webroot into the managed store (recording the original path as oldURL so legacy links + /// keep working via the oldURL lookup), and bulk-encrypt existing unencrypted files. + /// + public class CmsFileImportService : ICmsFileImportService + { + private readonly VIPERContext _context; + private readonly ICmsFileStorageService _storage; + private readonly ICmsFileEncryptionService _encryption; + private readonly ICmsFileAuditService _audit; + private readonly IUserHelper _userHelper; + private readonly string? _legacyWebroot; + + public CmsFileImportService(VIPERContext context, ICmsFileStorageService storage, + ICmsFileEncryptionService encryption, ICmsFileAuditService audit, IUserHelper userHelper, + IConfiguration configuration) + { + _context = context; + _storage = storage; + _encryption = encryption; + _audit = audit; + _userHelper = userHelper; + _legacyWebroot = configuration["CMS:LegacyWebrootPath"] + ?? (HttpHelper.Environment?.EnvironmentName == "Development" ? @"C:\Sites\https\VIPER" : null); + } + + public async Task> ImportFilesAsync(CmsFileImportRequest request, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(_legacyWebroot)) + { + throw new InvalidOperationException("CMS:LegacyWebrootPath is not configured for this environment."); + } + if (!_storage.IsValidFolder(request.Folder)) + { + throw new ArgumentException("Invalid folder."); + } + + var permissions = request.Permissions + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim()) + .ToList(); + if (request.UseDefaultPermission == true) + { + string topFolder = request.Folder.Split(['\\', '/'], StringSplitOptions.None)[0]; + permissions.Add("SVMSecure." + topFolder); + } + permissions = permissions.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + var results = new List(); + foreach (var rawPath in request.FilePaths.Where(p => !string.IsNullOrWhiteSpace(p))) + { + results.Add(await ImportSingleFileAsync(rawPath.Trim(), request, permissions, ct)); + } + return results; + } + + private async Task ImportSingleFileAsync(string rawPath, CmsFileImportRequest request, + List permissions, CancellationToken ct) + { + var result = new CmsFileImportResult { FilePath = rawPath }; + try + { + string relative = rawPath.TrimStart('/', '\\').Replace('/', '\\'); + string sourcePath = Path.GetFullPath(Path.Join(_legacyWebroot, relative)); + string webroot = Path.GetFullPath(_legacyWebroot! + Path.DirectorySeparatorChar); + if (!sourcePath.StartsWith(webroot, StringComparison.OrdinalIgnoreCase)) + { + result.Message = "Path is outside the legacy webroot."; + return result; + } + if (!System.IO.File.Exists(sourcePath)) + { + result.Message = "File not found."; + return result; + } + string fileName = Path.GetFileName(sourcePath); + if (!CmsFileTypes.IsAllowedFileName(fileName)) + { + result.Message = "File type is not allowed."; + return result; + } + + bool encrypt = request.Encrypt ?? false; + string? dbKey = encrypt ? _encryption.GenerateKeyForDb() : null; + + // Copy to temp, transform, move into the store; the source is removed only + // after the import fully succeeds (legacy used move semantics). + string tempPath = Path.Join(Path.GetTempPath(), "Viper-CMS-Uploads", Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(tempPath)!); + System.IO.File.Copy(sourcePath, tempPath); + + string finalPath; + try + { + if (dbKey != null) + { + _encryption.EncryptFileInPlace(tempPath, dbKey); + } + finalPath = _storage.MoveIntoPlace(tempPath, request.Folder, fileName, makeUnique: true); + } + finally + { + if (System.IO.File.Exists(tempPath)) + { + System.IO.File.Delete(tempPath); + } + } + + string friendlyName = request.Folder.Replace('\\', '-').Replace('/', '-') + "-" + Path.GetFileName(finalPath); + if (await _context.Files.AnyAsync(f => f.FriendlyName == friendlyName, ct)) + { + _storage.DeleteManagedFile(finalPath); + result.Message = $"A file with the name {friendlyName} already exists."; + return result; + } + + var entity = new File + { + FileGuid = Guid.NewGuid(), + FilePath = finalPath, + Folder = request.Folder, + FriendlyName = friendlyName, + Encrypted = encrypt, + Key = dbKey, + Description = "Automatically imported from Viper", + AllowPublicAccess = request.AllowPublicAccess ?? false, + OldUrl = "/" + relative.Replace('\\', '/'), + ModifiedOn = DateTime.Now, + ModifiedBy = CurrentLoginId() + }; + foreach (var permission in permissions) + { + entity.FileToPermissions.Add(new Viper.Models.VIPER.FileToPermission { FileGuid = entity.FileGuid, Permission = permission }); + } + + _context.Files.Add(entity); + _audit.Audit(entity, CmsFileAuditActions.ImportFile, $"Imported from {entity.OldUrl}"); + try + { + await _context.SaveChangesAsync(ct); + } + catch (Exception ex) when (ex is DbUpdateException or SqlException) + { + // Drop the failed entities so they can't poison later saves in this batch, + // and remove the stored copy; the untouched source can be re-imported. + _context.ChangeTracker.Clear(); + _storage.DeleteManagedFile(finalPath); + result.Message = ex.InnerException?.Message ?? ex.Message; + return result; + } + + System.IO.File.Delete(sourcePath); + + result.Success = true; + result.FileGuid = entity.FileGuid; + result.FriendlyName = friendlyName; + return result; + } + catch (IOException ex) + { + result.Message = ex.Message; + return result; + } + catch (UnauthorizedAccessException ex) + { + result.Message = ex.Message; + return result; + } + catch (InvalidOperationException ex) + { + result.Message = ex.Message; + return result; + } + catch (ArgumentException ex) + { + result.Message = ex.Message; + return result; + } + } + + public async Task> BulkEncryptAsync(List fileGuids, CancellationToken ct = default) + { + var results = new List(); + foreach (var fileGuid in fileGuids.Distinct()) + { + results.Add(await EncryptSingleFileAsync(fileGuid, ct)); + } + return results; + } + + private async Task EncryptSingleFileAsync(Guid fileGuid, CancellationToken ct) + { + var result = new CmsBulkEncryptResult { FileGuid = fileGuid }; + var file = await _context.Files.FirstOrDefaultAsync(f => f.FileGuid == fileGuid, ct); + if (file == null) + { + result.Message = "File not found."; + return result; + } + result.FriendlyName = file.FriendlyName; + if (file.Encrypted) + { + result.Message = "Already encrypted."; + return result; + } + if (!_storage.ManagedFileExists(file.FilePath)) + { + result.Message = "File is missing on disk."; + return result; + } + + string? dbKey = null; + try + { + dbKey = _encryption.GenerateKeyForDb(); + _encryption.EncryptFileInPlace(file.FilePath, dbKey); + file.Key = dbKey; + file.Encrypted = true; + file.ModifiedOn = DateTime.Now; + file.ModifiedBy = CurrentLoginId(); + _audit.Audit(file, CmsFileAuditActions.EditFile, "Encrypted file (bulk)"); + // The file is already encrypted on disk; cancelling this save would lose the + // key, so it runs to completion even if the request is aborted. + await _context.SaveChangesAsync(CancellationToken.None); + result.Success = true; + } + catch (Exception ex) when (ex is DbUpdateException or SqlException) + { + RollBackUnsavedEncryption(file, dbKey!, ex, result); + } + catch (IOException ex) + { + result.Message = ex.Message; + } + catch (UnauthorizedAccessException ex) + { + result.Message = ex.Message; + } + catch (InvalidOperationException ex) + { + result.Message = ex.Message; + } + return result; + } + + /// + /// A failed save means the key was never persisted, so decrypt the file back to plain + /// text and drop the pending changes so they can't poison later saves in the batch. + /// + private void RollBackUnsavedEncryption(File file, string dbKey, Exception saveEx, CmsBulkEncryptResult result) + { + _context.ChangeTracker.Clear(); + try + { + _encryption.DecryptFileInPlace(file.FilePath, dbKey); + result.Message = saveEx.InnerException?.Message ?? saveEx.Message; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + result.Message = $"{saveEx.Message} The file could not be restored ({ex.Message}); " + + "it is encrypted on disk but the key was not saved. Restore it from backup."; + } + } + + private string CurrentLoginId() + { + return _userHelper.GetCurrentUser()?.LoginId ?? "unknown"; + } + } +} diff --git a/web/Areas/CMS/Services/CmsUserPhotoService.cs b/web/Areas/CMS/Services/CmsUserPhotoService.cs new file mode 100644 index 000000000..c568966cb --- /dev/null +++ b/web/Areas/CMS/Services/CmsUserPhotoService.cs @@ -0,0 +1,129 @@ +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Students.Services; +using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; + +namespace Viper.Areas.CMS.Services +{ + public interface ICmsUserPhotoService + { + /// + /// Get a user photo by any supported id. Resolution order matches legacy userPhoto.cfc: + /// alternate profile photo (by IamId, only when requested via preferAltPhoto), + /// then id-card photo (by MailId), then the default "no picture" image. + /// + Task GetUserPhotoAsync(string? mailId, string? loginId, string? iamId, bool preferAltPhoto, CancellationToken ct = default); + } + + /// + /// User photos for the CMS area (legacy userPhoto.cfc). Id-card photo lookup, caching, and + /// the default-photo fallback are delegated to the Students area IPhotoService so there is + /// a single photo pipeline; this service adds AAUD id resolution and the alternate + /// ProfilePhotos store. + /// + public partial class CmsUserPhotoService : ICmsUserPhotoService + { + private readonly AAUDContext _aaudContext; + private readonly IPhotoService _photoService; + private readonly ILogger _logger; + private readonly string _profilePhotoPath; + + [GeneratedRegex("^[a-zA-Z0-9.-]+$")] + private static partial Regex SafeIdRegex(); + + public CmsUserPhotoService(AAUDContext aaudContext, IPhotoService photoService, + IConfiguration configuration, ILogger logger) + { + _aaudContext = aaudContext; + _photoService = photoService; + _logger = logger; + // Alternate profile photos live beside IDCardPhotos under the file storage root. + _profilePhotoPath = configuration["CMS:ProfilePhotoPath"] + ?? Path.Join(Data.CMS.GetRootFileFolder(), "ProfilePhotos"); + } + + public async Task GetUserPhotoAsync(string? mailId, string? loginId, string? iamId, + bool preferAltPhoto, CancellationToken ct = default) + { + // Resolve whichever id was provided to the person's mailId (+ iamId when needed). + (mailId, iamId) = await ResolveIdsAsync(mailId, loginId, iamId, needIamId: preferAltPhoto, ct); + + if (preferAltPhoto && iamId != null) + { + var altPhoto = await ReadAltPhotoAsync(iamId, ct); + if (altPhoto != null) + { + return altPhoto; + } + } + + return await _photoService.GetStudentPhotoAsync(mailId ?? string.Empty); + } + + private async Task<(string? MailId, string? IamId)> ResolveIdsAsync(string? mailId, string? loginId, + string? iamId, bool needIamId, CancellationToken ct) + { + // Skip the lookup when every id the caller needs is already known. A mailId alone + // serves the id-card photo with no DB query (the legacy hot path for tags). + if (!string.IsNullOrEmpty(mailId) && (!string.IsNullOrEmpty(iamId) || !needIamId)) + { + return (mailId, iamId); + } + + var query = _aaudContext.AaudUsers.AsNoTracking().Where(u => u.Current != 0); + if (!string.IsNullOrEmpty(mailId)) + { + query = query.Where(u => u.MailId == mailId); + } + else if (!string.IsNullOrEmpty(loginId)) + { + query = query.Where(u => u.LoginId == loginId); + } + else if (!string.IsNullOrEmpty(iamId)) + { + query = query.Where(u => u.IamId == iamId); + } + else + { + return (null, null); + } + + var user = await query + .Select(u => new { u.MailId, u.IamId }) + .FirstOrDefaultAsync(ct); + return user == null ? (mailId, iamId) : (user.MailId ?? mailId, user.IamId ?? iamId); + } + + private async Task ReadAltPhotoAsync(string iamId, CancellationToken ct) + { + if (!SafeIdRegex().IsMatch(iamId)) + { + _logger.LogWarning("Rejected alt photo request with invalid iamId {IamId}", LogSanitizer.SanitizeId(iamId)); + return null; + } + + var photoPath = Path.GetFullPath(Path.Join(_profilePhotoPath, Path.GetFileName(iamId) + ".jpg")); + var root = Path.GetFullPath(_profilePhotoPath + Path.DirectorySeparatorChar); + if (!photoPath.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !System.IO.File.Exists(photoPath)) + { + return null; + } + + try + { + return await System.IO.File.ReadAllBytesAsync(photoPath, ct); + } + catch (IOException ex) + { + _logger.LogError(ex, "Error reading alt photo for {IamId}", LogSanitizer.SanitizeId(iamId)); + return null; + } + catch (UnauthorizedAccessException ex) + { + _logger.LogError(ex, "Access denied reading alt photo for {IamId}", LogSanitizer.SanitizeId(iamId)); + return null; + } + } + } +} diff --git a/web/Program.cs b/web/Program.cs index 8f87dc1e7..669006b0f 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -27,6 +27,7 @@ using QuestPDF.Infrastructure; using Scrutor; using Viper; +using Viper.Areas.CMS.Services; using Viper.Areas.Effort; using Viper.Areas.Effort.Data; using Viper.Areas.Effort.Services.Harvest; @@ -96,6 +97,9 @@ // in Development. See ForwardedHeadersExtensions. builder.Services.AddViperForwardedHeaders(builder.Environment, logger); + // Rate limiting for CMS downloads (single files + ZIP archives). See CmsDownloadRateLimiting. + builder.Services.AddCmsDownloadRateLimiting(builder.Configuration); + // Add services to the container. builder.Services.AddControllersWithViews(options => { @@ -495,6 +499,9 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db // still before health checks, Hangfire, and controllers, which need an authenticated user. app.UseAuthentication(); app.UseAuthorization(); + // After auth so download rate-limit buckets can key on the logged-in user + // instead of the client IP (kinder to shared campus NAT). + app.UseRateLimiter(); app.UseCookiePolicy(); app.UseSession();