diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 80cf99d50..29684d05c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-ef": { - "version": "10.0.9", + "version": "10.0.10", "commands": [ "dotnet-ef" ] @@ -15,7 +15,7 @@ ] }, "jetbrains.resharper.globaltools": { - "version": "2026.1.3", + "version": "2026.2.0", "commands": [ "jb" ] diff --git a/test/CTS/AssessmentControllerTest.cs b/test/CTS/AssessmentControllerTest.cs index 3f166ad2d..a5c8141da 100644 --- a/test/CTS/AssessmentControllerTest.cs +++ b/test/CTS/AssessmentControllerTest.cs @@ -123,10 +123,10 @@ public async Task GetAssessmentCheck() var actrlAsChief = GetAssessmentController(SetupUsers.UserType.Chief); var encounterIdExists = SetupAssessments.GetEncounters().First().EncounterId; - var encounterIdOtherFac = SetupAssessments.GetEncounters().Where(e => e.EnteredBy != SetupUsers.facultyUser.AaudUserId).First().EncounterId; - var encounterIdOtherStd = SetupAssessments.GetEncounters().Where(e => e.StudentUserId != SetupUsers.studentUser1.AaudUserId).First().EncounterId; - var encounterIdChief = SetupAssessments.GetEncounters().Where(e => e.ServiceId == SetupServices.ServiceChiefs[0].ServiceId).First().EncounterId; - var encounterIdNotChief = SetupAssessments.GetEncounters().Where(e => e.ServiceId != SetupServices.ServiceChiefs[0].ServiceId).First().EncounterId; + var encounterIdOtherFac = SetupAssessments.GetEncounters().First(e => e.EnteredBy != SetupUsers.facultyUser.AaudUserId).EncounterId; + var encounterIdOtherStd = SetupAssessments.GetEncounters().First(e => e.StudentUserId != SetupUsers.studentUser1.AaudUserId).EncounterId; + var encounterIdChief = SetupAssessments.GetEncounters().First(e => e.ServiceId == SetupServices.ServiceChiefs[0].ServiceId).EncounterId; + var encounterIdNotChief = SetupAssessments.GetEncounters().First(e => e.ServiceId != SetupServices.ServiceChiefs[0].ServiceId).EncounterId; var encounterIdNotExists = 99999; //act diff --git a/test/CTS/SetupAssessments.cs b/test/CTS/SetupAssessments.cs index a32b41d57..f329ffb1f 100644 --- a/test/CTS/SetupAssessments.cs +++ b/test/CTS/SetupAssessments.cs @@ -23,7 +23,7 @@ internal static class SetupAssessments FullName = SetupUsers.studentUser1.DisplayLastName + ", " + SetupUsers.studentUser1.DisplayFirstName, MailId = "", }, - EnteredByPerson = SetupPeople.GetPeople().Where(p => p.PersonId == SetupUsers.facultyUser.AaudUserId).FirstOrDefault(), + EnteredByPerson = SetupPeople.GetPeople().FirstOrDefault(p => p.PersonId == SetupUsers.facultyUser.AaudUserId), ServiceId = 1, }, new Encounter @@ -38,7 +38,7 @@ internal static class SetupAssessments FullName = SetupUsers.studentUser1.DisplayLastName + ", " + SetupUsers.studentUser1.DisplayFirstName, MailId = "", }, - EnteredByPerson = SetupPeople.GetPeople().Where(p => p.PersonId == SetupUsers.facultyUser.AaudUserId).FirstOrDefault(), + EnteredByPerson = SetupPeople.GetPeople().FirstOrDefault(p => p.PersonId == SetupUsers.facultyUser.AaudUserId), ServiceId = 2, }, new Encounter @@ -53,7 +53,7 @@ internal static class SetupAssessments FullName = SetupUsers.studentUser2.DisplayLastName + ", " + SetupUsers.studentUser2.DisplayFirstName, MailId = "", }, - EnteredByPerson = SetupPeople.GetPeople().Where(p => p.PersonId == SetupUsers.otherFacultyUser.AaudUserId).FirstOrDefault(), + EnteredByPerson = SetupPeople.GetPeople().FirstOrDefault(p => p.PersonId == SetupUsers.otherFacultyUser.AaudUserId), ServiceId = 3, }, }; diff --git a/test/Viper.test.csproj b/test/Viper.test.csproj index af5cccc12..a2c0e746d 100644 --- a/test/Viper.test.csproj +++ b/test/Viper.test.csproj @@ -22,23 +22,25 @@ - - + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/web/Areas/Effort/Services/CourseClassificationService.cs b/web/Areas/Effort/Services/CourseClassificationService.cs index e4865b212..02b2f9bc3 100644 --- a/web/Areas/Effort/Services/CourseClassificationService.cs +++ b/web/Areas/Effort/Services/CourseClassificationService.cs @@ -34,7 +34,7 @@ public bool Is199299Course(string? crseNumb) // Match 199 or 299 followed by non-digit or end of string // Valid: "199", "299", "199A", "299R", "199B" // Invalid: "1990", "2995", "19900" - return Regex.IsMatch(crseNumb.Trim(), @"^(199|299)([^0-9]|$)", RegexOptions.IgnoreCase); + return Regex.IsMatch(crseNumb.Trim(), @"^(199|299)([^0-9]|$)", RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); } /// diff --git a/web/Areas/RAPS/Controllers/AdGroupRolesController.cs b/web/Areas/RAPS/Controllers/AdGroupRolesController.cs index 8412ff84e..80be33037 100644 --- a/web/Areas/RAPS/Controllers/AdGroupRolesController.cs +++ b/web/Areas/RAPS/Controllers/AdGroupRolesController.cs @@ -27,8 +27,8 @@ public AdGroupRolesController(RAPSContext context) private bool GroupExists(int groupId) { var group = _context.OuGroups - .Where(gr => gr.OugroupId == groupId) - .FirstOrDefault(); + .AsNoTracking() + .FirstOrDefault(gr => gr.OugroupId == groupId); return group != null; } diff --git a/web/Areas/RAPS/Services/RAPSCacheService.cs b/web/Areas/RAPS/Services/RAPSCacheService.cs index 3156d05c0..1590c1fb6 100644 --- a/web/Areas/RAPS/Services/RAPSCacheService.cs +++ b/web/Areas/RAPS/Services/RAPSCacheService.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using Viper.Classes.SQLContext; using Viper.Models.AAUD; @@ -16,7 +17,7 @@ public RAPSCacheService(RAPSContext rapsContext, AAUDContext aaudContext, IUserH public void ClearCachedRolesAndPermissionsForUser(string mothraId) { - AaudUser? user = aaudContext.AaudUsers.Where(u => u.MothraId == mothraId).FirstOrDefault(); + AaudUser? user = aaudContext.AaudUsers.AsNoTracking().FirstOrDefault(u => u.MothraId == mothraId); if (user != null) { userHelper.ClearCachedRolesAndPermissions(user); diff --git a/web/Areas/RAPS/Services/VMACSExport.cs b/web/Areas/RAPS/Services/VMACSExport.cs index 64c465b21..062df033e 100644 --- a/web/Areas/RAPS/Services/VMACSExport.cs +++ b/web/Areas/RAPS/Services/VMACSExport.cs @@ -214,7 +214,9 @@ join role in _RAPSContext.TblRoles on rm.RoleId equals role.RoleId //Get a single record with their ids and blank role so that an empty permissions array can be pushed. if (userList.Count == 0 && loginId.Length > 0) { - var user = _RAPSContext.VwAaudUser.Where(a => a.LoginId == loginId).FirstOrDefault(); + var user = _RAPSContext.VwAaudUser + .AsNoTracking() + .FirstOrDefault(a => a.LoginId == loginId); if (user != null) { userList.Add(new UserList diff --git a/web/Areas/RAPS/Views/Groups/List.cshtml b/web/Areas/RAPS/Views/Groups/List.cshtml index f5b73eab9..d0c2c6a57 100644 --- a/web/Areas/RAPS/Views/Groups/List.cshtml +++ b/web/Areas/RAPS/Views/Groups/List.cshtml @@ -136,11 +136,11 @@ }, async mounted() { this.groupTable.load(this) - this.ouGroups = await viperFetch(this, "Groups/OU") + this.ouGroups = (await viperFetch(this, "Groups/OU")) ?? [] this.ouGroups = this.ouGroups .map(g => ({ label: "OU: " + g.cn, value: g.distinguishedName })) - this.adGroups = await viperFetch(this, "Groups/AD") + this.adGroups = (await viperFetch(this, "Groups/AD")) ?? [] this.adGroups = this.adGroups .map(g => ({ label: "AD: " + (g.displayName ?? g.samAccountName), value: g.distinguishedName })) diff --git a/web/Areas/RAPS/Views/Groups/Members.cshtml b/web/Areas/RAPS/Views/Groups/Members.cshtml index 5f1e78fb8..4ffe0b6ba 100644 --- a/web/Areas/RAPS/Views/Groups/Members.cshtml +++ b/web/Areas/RAPS/Views/Groups/Members.cshtml @@ -9,8 +9,8 @@ - - + + { return {label: formatDate(d), value: d} }) diff --git a/web/Areas/RAPS/Views/Members/Permissions.cshtml b/web/Areas/RAPS/Views/Members/Permissions.cshtml index 84e57c2b1..24b924e56 100644 --- a/web/Areas/RAPS/Views/Members/Permissions.cshtml +++ b/web/Areas/RAPS/Views/Members/Permissions.cshtml @@ -251,7 +251,7 @@ this.selectedPermissions = [] }, loadMember: async function () { - this.member = await viperFetch(this, "members/" + this.memberId) + this.member = (await viperFetch(this, "members/" + this.memberId)) ?? {} } }, mounted() { diff --git a/web/Areas/RAPS/Views/Members/RSOP.cshtml b/web/Areas/RAPS/Views/Members/RSOP.cshtml index ceae260bf..f3479ddec 100644 --- a/web/Areas/RAPS/Views/Members/RSOP.cshtml +++ b/web/Areas/RAPS/Views/Members/RSOP.cshtml @@ -46,7 +46,7 @@ }, methods: { loadMember: async function () { - this.member = await viperFetch(this, "Members/" + this.memberId) + this.member = (await viperFetch(this, "Members/" + this.memberId)) ?? {} } }, async mounted() { diff --git a/web/Areas/RAPS/Views/Members/Roles.cshtml b/web/Areas/RAPS/Views/Members/Roles.cshtml index 7ef9a8bca..39b3b2558 100644 --- a/web/Areas/RAPS/Views/Members/Roles.cshtml +++ b/web/Areas/RAPS/Views/Members/Roles.cshtml @@ -265,7 +265,7 @@ this.selectedRoles = [] }, loadMember: async function () { - this.member = await viperFetch(this, "Members/" + this.memberId) + this.member = (await viperFetch(this, "Members/" + this.memberId)) ?? {} }, pushToVMACS: async function (skipToggleCheck) { if (skipToggleCheck || this.updateVMACS) { diff --git a/web/Areas/RAPS/Views/Permissions/AllMembers.cshtml b/web/Areas/RAPS/Views/Permissions/AllMembers.cshtml index 8654d27f3..604263e91 100644 --- a/web/Areas/RAPS/Views/Permissions/AllMembers.cshtml +++ b/web/Areas/RAPS/Views/Permissions/AllMembers.cshtml @@ -56,7 +56,7 @@ }, methods: { loadPermission: async function () { - this.permission = await viperFetch(this, "Permissions/" + this.permissionId) + this.permission = (await viperFetch(this, "Permissions/" + this.permissionId)) ?? {} } }, async mounted() { diff --git a/web/Areas/RAPS/Views/Permissions/Members.cshtml b/web/Areas/RAPS/Views/Permissions/Members.cshtml index e8aa02346..7db52a135 100644 --- a/web/Areas/RAPS/Views/Permissions/Members.cshtml +++ b/web/Areas/RAPS/Views/Permissions/Members.cshtml @@ -170,7 +170,7 @@ }, methods: { loadPermission: async function () { - this.permission = await viperFetch(this, "Permissions/" + this.permissionId) + this.permission = (await viperFetch(this, "Permissions/" + this.permissionId)) ?? {} }, memberSearch: function (val, update, abort) { if (val.length < 3) { diff --git a/web/Areas/RAPS/Views/Permissions/Roles.cshtml b/web/Areas/RAPS/Views/Permissions/Roles.cshtml index 95d512689..60e736627 100644 --- a/web/Areas/RAPS/Views/Permissions/Roles.cshtml +++ b/web/Areas/RAPS/Views/Permissions/Roles.cshtml @@ -102,7 +102,7 @@ }, methods: { loadPermission: async function () { - this.permission = await viperFetch(this, "Permissions/" + this.permissionId) + this.permission = (await viperFetch(this, "Permissions/" + this.permissionId)) ?? {} }, updateRolePermissions: async function (allowAccess = 1) { var roleIds = this.selectedPermissions.reduce( (ids, sp) => {ids.push(sp.roleId); return ids;}, [] ) diff --git a/web/Areas/RAPS/Views/Permissions/RolesRO.cshtml b/web/Areas/RAPS/Views/Permissions/RolesRO.cshtml index f7d3c8b98..28303c8ac 100644 --- a/web/Areas/RAPS/Views/Permissions/RolesRO.cshtml +++ b/web/Areas/RAPS/Views/Permissions/RolesRO.cshtml @@ -49,7 +49,7 @@ }, methods: { loadPermission: async function () { - this.permission = await viperFetch(this, "Permissions/" + this.permissionId) + this.permission = (await viperFetch(this, "Permissions/" + this.permissionId)) ?? {} }, loadTables: async function () { await this.rolePermissionTable.load(this) diff --git a/web/Areas/RAPS/Views/Roles/ApplyTemplate.cshtml b/web/Areas/RAPS/Views/Roles/ApplyTemplate.cshtml index 03b7c86d4..fa4231afa 100644 --- a/web/Areas/RAPS/Views/Roles/ApplyTemplate.cshtml +++ b/web/Areas/RAPS/Views/Roles/ApplyTemplate.cshtml @@ -17,7 +17,7 @@ Review Changes - + @@ -50,6 +50,8 @@ membersFound: false, //show table if a matching user was found roleTemplateId: 0, roleTemplate: {}, + // Starts false so applying is disabled until the template is read. + templateLoaded: false, loginids: "", userSearch: "", userSearchResults: [], @@ -142,7 +144,12 @@ }, async mounted() { this.roleTemplateId = this.urlParams.get("roleTemplateId") - this.roleTemplate = await viperFetch(this, "RoleTemplates/" + this.roleTemplateId) + // roleTemplateRoles must be an array: the VMACS push path calls .reduce() on it. + // Track load failure separately so an unread template is never mistaken for + // an empty one, which would push an empty role list to VMACS. + const template = await viperFetch(this, "RoleTemplates/" + this.roleTemplateId) + this.templateLoaded = template !== undefined + this.roleTemplate = template ?? { roleTemplateRoles: [] } this.VMACSPush = this.urlPath.indexOf("/VMACS.") > 0 }, watch: { diff --git a/web/Areas/RAPS/Views/Roles/DelegateRoles.cshtml b/web/Areas/RAPS/Views/Roles/DelegateRoles.cshtml index 0f18480aa..2b1818408 100644 --- a/web/Areas/RAPS/Views/Roles/DelegateRoles.cshtml +++ b/web/Areas/RAPS/Views/Roles/DelegateRoles.cshtml @@ -12,7 +12,7 @@ :pagination="{rowsPerPage:0}" :loading="loadingRoles"> @@ -26,6 +26,8 @@ roles: [], selectedRoles: [], loadingRoles: false, + // Starts false so the page is disabled until the initial read succeeds. + rolesLoaded: false, columns: [ { name: "role", label: "Role", field: "friendlyName", align: "left" }, { name: "description", label: "Description", field: "description", align: "left" } @@ -34,13 +36,24 @@ }, methods: { loadRoles: async function() { + this.rolesLoaded = false var childRoles = await viperFetch(this, "Roles/ControlledBy/" + + this.urlParams.get("roleId")) - var allRoles = (await viperFetch(this, "Roles?Application=0")) - .filter(r => childRoles.find(cr => cr.roleId === r.roleId) === undefined) - this.selectedRoles = childRoles;//.reduce((result, cr) => { result.push(cr.roleId); return result }, []) - this.roles = childRoles.concat(allRoles) + var allRoles = await viperFetch(this, "Roles?Application=0") + // A failed or still-pending read must not look like "nothing is + // selected": submitting that would PUT an empty list and wipe the + // existing delegations. Stay disabled until both reads succeed. + if (childRoles === undefined || allRoles === undefined) { + return + } + this.selectedRoles = childRoles + this.roles = childRoles.concat( + allRoles.filter(r => childRoles.find(cr => cr.roleId === r.roleId) === undefined)) + this.rolesLoaded = true }, submitChanges: async function() { + if (!this.rolesLoaded) { + return + } var roleIds = this.selectedRoles.reduce( (result, role) => { result.push(role.roleId) return result @@ -57,8 +70,8 @@ } }, async mounted() { - this.loadRoles() - this.role = await viperFetch(this, "Roles/" + this.urlParams.get("roleId")) + await this.loadRoles() + this.role = (await viperFetch(this, "Roles/" + this.urlParams.get("roleId"))) ?? {} } }) diff --git a/web/Areas/RAPS/Views/Roles/Members.cshtml b/web/Areas/RAPS/Views/Roles/Members.cshtml index d852e60d9..3ce720a0d 100644 --- a/web/Areas/RAPS/Views/Roles/Members.cshtml +++ b/web/Areas/RAPS/Views/Roles/Members.cshtml @@ -162,7 +162,8 @@ }, methods: { loadRole: async function () { - this.role = await viperFetch(this, "Roles/" + this.roleId) + // instance must be a string: mounted() calls role.instance.indexOf(). + this.role = (await viperFetch(this, "Roles/" + this.roleId)) ?? { instance: "" } }, memberSearch: function (val, update, abort) { if (val.length < 3) { diff --git a/web/Areas/RAPS/Views/Roles/Permissions.cshtml b/web/Areas/RAPS/Views/Roles/Permissions.cshtml index 2e1b7614e..6bfbc4e05 100644 --- a/web/Areas/RAPS/Views/Roles/Permissions.cshtml +++ b/web/Areas/RAPS/Views/Roles/Permissions.cshtml @@ -80,7 +80,7 @@ }, methods: { loadRole: async function () { - this.role = await viperFetch(this, "Roles/" + this.roleId) + this.role = (await viperFetch(this, "Roles/" + this.roleId)) ?? {} }, loadRolePermissions: async function() { await this.rolePermissionTable.load(this) diff --git a/web/Areas/RAPS/Views/Roles/TemplateRoles.cshtml b/web/Areas/RAPS/Views/Roles/TemplateRoles.cshtml index 0a0f33925..89f742c15 100644 --- a/web/Areas/RAPS/Views/Roles/TemplateRoles.cshtml +++ b/web/Areas/RAPS/Views/Roles/TemplateRoles.cshtml @@ -66,7 +66,7 @@ }, async mounted() { this.loadRoles() - this.roleTemplate = await viperFetch(this, "RoleTemplates/" + this.urlParams.get("roleTemplateId")) + this.roleTemplate = (await viperFetch(this, "RoleTemplates/" + this.urlParams.get("roleTemplateId"))) ?? {} } }) diff --git a/web/Areas/Students/Services/PhotoExportService.cs b/web/Areas/Students/Services/PhotoExportService.cs index 3729c75eb..db4aaa89b 100644 --- a/web/Areas/Students/Services/PhotoExportService.cs +++ b/web/Areas/Students/Services/PhotoExportService.cs @@ -64,7 +64,7 @@ public class PhotoExportService : IPhotoExportService // and our own home-grown checker flag 3+ consecutive whitespace chars // ("RepeatedBlanks"); collapsing them on the way out is a safe no-op for // clean data and a structural improvement for messy data. - private static readonly Regex RepeatedWhitespaceRegex = new(@"\s{2,}", RegexOptions.Compiled); + private static readonly Regex RepeatedWhitespaceRegex = new(@"\s{2,}", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); private static string CollapseWhitespace(string? text) => string.IsNullOrEmpty(text) ? string.Empty : RepeatedWhitespaceRegex.Replace(text, " ").Trim(); diff --git a/web/Areas/Students/Services/PhotoService.cs b/web/Areas/Students/Services/PhotoService.cs index 93ef0788e..3f8f45c4b 100644 --- a/web/Areas/Students/Services/PhotoService.cs +++ b/web/Areas/Students/Services/PhotoService.cs @@ -27,7 +27,7 @@ public class PhotoService : IPhotoService private const int DefaultCacheDurationHours = 24; // Compiled regex for mailId validation - compiled once for performance - private static readonly Regex MailIdValidationRegex = new("^[a-z0-9.-]+$", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex MailIdValidationRegex = new("^[a-z0-9.-]+$", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1)); public PhotoService(IConfiguration configuration, IMemoryCache cache, ILogger logger, IWebHostEnvironment webHostEnvironment) { diff --git a/web/Viper.csproj b/web/Viper.csproj index 7b8e781c2..ad780ee0d 100644 --- a/web/Viper.csproj +++ b/web/Viper.csproj @@ -42,20 +42,20 @@ - - - + + + - - + + - + - + - + @@ -71,18 +71,17 @@ - - - + + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - diff --git a/web/ViteProxyHelpers.cs b/web/ViteProxyHelpers.cs index 0e4e4f9d4..a1f8a00d7 100644 --- a/web/ViteProxyHelpers.cs +++ b/web/ViteProxyHelpers.cs @@ -41,10 +41,15 @@ internal static class ViteProxyHelpers private const string VueAppRoutePattern = @"^/({0})(/.*)?$"; private const string VueAppAssetPattern = @"^/({0})/.*\.(js|ts|css|map|vue|json)$|^/({0})\.(js|ts|css|map|vue|json)$"; + // Bounds matching so a pathological request path can't pin a request thread. + // Must stay above the regex fields: static initialisers run in textual order. + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(1); + // Regex for built asset files with Vite hashes (used to skip proxying built assets) private static readonly Regex AssetHashRegex = new Regex( $@"{Regex.Escape(ViteAssetsBasePath)}{AssetHashPattern}{SupportedAssetExtensions}$", - RegexOptions.IgnoreCase | RegexOptions.Compiled + RegexOptions.IgnoreCase | RegexOptions.Compiled, + RegexTimeout ); // Cached compiled regexes - initialized once at startup @@ -76,15 +81,15 @@ private static void InitializeRegexes(string[] vueAppNames) if (string.IsNullOrEmpty(vueAppsPattern)) { // @"(?!)" is a negative lookahead that never matches any input; used here to ensure no routes/assets match when no app names are provided. - _vueAppRouteRegex = new Regex(@"(?!)", RegexOptions.Compiled); - _vueAppAssetRegex = new Regex(@"(?!)", RegexOptions.Compiled); + _vueAppRouteRegex = new Regex(@"(?!)", RegexOptions.Compiled, RegexTimeout); + _vueAppAssetRegex = new Regex(@"(?!)", RegexOptions.Compiled, RegexTimeout); } else { _vueAppRouteRegex = new Regex(string.Format(VueAppRoutePattern, vueAppsPattern), - RegexOptions.Compiled | RegexOptions.IgnoreCase); + RegexOptions.Compiled | RegexOptions.IgnoreCase, RegexTimeout); _vueAppAssetRegex = new Regex(string.Format(VueAppAssetPattern, vueAppsPattern), - RegexOptions.Compiled | RegexOptions.IgnoreCase); + RegexOptions.Compiled | RegexOptions.IgnoreCase, RegexTimeout); } } } @@ -98,7 +103,7 @@ public static bool ShouldProxyToVite(HttpContext context, string[] vueAppNames) var path = context.Request.Path; // Skip proxying built asset files with hashes - serve as static files - if (path.HasValue && AssetHashRegex.IsMatch(path.Value)) + if (path.HasValue && IsMatchWithinTimeout(AssetHashRegex, path.Value)) { return false; } @@ -122,19 +127,36 @@ public static bool ShouldProxyToVite(HttpContext context, string[] vueAppNames) var pathValue = path.Value; // Match Vue app routes: /CTS, /Computing, /Students (exact match or with sub-paths) - if (_vueAppRouteRegex!.IsMatch(pathValue)) + if (IsMatchWithinTimeout(_vueAppRouteRegex!, pathValue)) { return true; } // Match: /AppName/file.ext (assets in app subdirectories) // OR: /appname.ext (root-level entry files like /cts.ts) - return _vueAppAssetRegex!.IsMatch(pathValue); + return IsMatchWithinTimeout(_vueAppAssetRegex!, pathValue); } return false; } + /// + /// Runs a match under the configured timeout. A timed-out match is treated as a + /// non-match so the request falls through to normal handling, which is how it + /// behaved before timeouts were added, rather than surfacing as a 500. + /// + private static bool IsMatchWithinTimeout(Regex regex, string input) + { + try + { + return regex.IsMatch(input); + } + catch (RegexMatchTimeoutException) + { + return false; + } + } + /// /// Builds the target URL for proxying to Vite development server ///