diff --git a/test/CMS/DownloadZipSecurityTests.cs b/test/CMS/DownloadZipSecurityTests.cs new file mode 100644 index 000000000..a3720fd8d --- /dev/null +++ b/test/CMS/DownloadZipSecurityTests.cs @@ -0,0 +1,298 @@ +using CmsData = Viper.Areas.CMS.Services.CmsFilePathSafety; + +namespace Viper.test.CMS; + +/// +/// Security tests for the helpers that back CMS.DownloadZip. +/// Covers the filename sanitizer used for the Content-Disposition header +/// and the per-request temp-archive path builder (VPR-138). +/// +public sealed class DownloadZipSecurityTests +{ + #region SanitizeDownloadName + + [Theory] + [InlineData(@"\..\..\evil.zip")] + [InlineData("../../evil.zip")] + [InlineData(@"C:\Windows\Temp\evil.zip")] + [InlineData("../evil")] + public void SanitizeDownloadName_StripsPathSeparatorsAndTraversal(string input) + { + var result = CmsData.SanitizeDownloadName(input); + + Assert.DoesNotContain('\\', result); + Assert.DoesNotContain('/', result); + Assert.DoesNotContain("..", result); + Assert.EndsWith(".zip", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("!!!")] + [InlineData("///")] + [InlineData(@"\\\")] + [InlineData(".")] + [InlineData("..")] + [InlineData("...")] + [InlineData(". .")] + public void SanitizeDownloadName_NullEmptyOrJunk_ReturnsDefault(string? input) + { + var result = CmsData.SanitizeDownloadName(input); + + Assert.Equal("FileDownload.zip", result); + } + + [Fact] + public void SanitizeDownloadName_NameWithoutExtension_AppendsZip() + { + var result = CmsData.SanitizeDownloadName("export"); + + Assert.Equal("export.zip", result); + } + + [Fact] + public void SanitizeDownloadName_NonZipExtension_ForcesZipSuffix() + { + // Documented choice: preserve the original name and append .zip so the + // response MIME (application/zip) always matches the filename. + var result = CmsData.SanitizeDownloadName("export.exe"); + + Assert.EndsWith(".zip", result, StringComparison.OrdinalIgnoreCase); + Assert.Equal("export.exe.zip", result); + } + + [Fact] + public void SanitizeDownloadName_AlreadyZip_IsPreserved() + { + var result = CmsData.SanitizeDownloadName("monthly-report.zip"); + + Assert.Equal("monthly-report.zip", result); + } + + [Fact] + public void SanitizeDownloadName_CaseInsensitiveZipExtension_IsPreserved() + { + var result = CmsData.SanitizeDownloadName("REPORT.ZIP"); + + Assert.Equal("REPORT.ZIP", result); + } + + [Theory] + [InlineData("CON")] + [InlineData("PRN")] + [InlineData("AUX")] + [InlineData("NUL")] + [InlineData("COM1")] + [InlineData("LPT1")] + [InlineData("con")] + [InlineData("CON.txt")] + public void SanitizeDownloadName_ReservedWindowsDeviceNames_ReturnsDefault(string input) + { + var result = CmsData.SanitizeDownloadName(input); + + Assert.Equal("FileDownload.zip", result); + } + + [Fact] + public void SanitizeDownloadName_AllowsSafeCharacters() + { + var result = CmsData.SanitizeDownloadName("My_File-01 v2.zip"); + + Assert.Equal("My_File-01 v2.zip", result); + } + + #endregion + + #region Regression guard (VPR-138) + + // Before the fix, DownloadZip built the on-disk temp archive path by + // concatenating GetRootFileFolder() + ticks + user-supplied fileName. + // A traversal payload in fileName (e.g. \..\..\evil.zip) escaped the + // CMS root. The fix splits the flow: user input feeds only the + // Content-Disposition name (SanitizeDownloadName); the on-disk path + // is generated from a server-side GUID under a dedicated temp root + // (BuildTempArchivePath). + // + // This test exercises both helpers as DownloadZip wires them and + // asserts the traversal payload cannot influence the on-disk path, + // even if a future refactor reintroduces the old concatenation. + [Theory] + [InlineData(@"\..\..\evil.zip")] + [InlineData("../../evil.zip")] + [InlineData(@"C:\Windows\System32\evil.zip")] + [InlineData(@"..\..\..\..\Windows\evil.zip")] + [InlineData("../../../../etc/passwd")] + public void Regression_Vpr138_TraversalPayload_CannotEscapeTempRoot(string attackPayload) + { + var tempRoot = CreateIsolatedTempRoot(); + try + { + var responseName = CmsData.SanitizeDownloadName(attackPayload); + var tempPath = CmsData.BuildTempArchivePath(tempRoot); + + Assert.DoesNotContain('\\', responseName); + Assert.DoesNotContain('/', responseName); + Assert.DoesNotContain("..", responseName); + Assert.EndsWith(".zip", responseName, StringComparison.OrdinalIgnoreCase); + + var resolvedRoot = Path.GetFullPath(tempRoot); + var resolvedPath = Path.GetFullPath(tempPath); + Assert.StartsWith( + resolvedRoot + Path.DirectorySeparatorChar, + resolvedPath, + StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("..", resolvedPath); + } + finally + { + Cleanup(tempRoot); + } + } + + #endregion + + #region SanitizeZipEntryName + + [Theory] + [InlineData(@"..\..\evil.txt", "evil.txt")] + [InlineData("../../evil.txt", "evil.txt")] + [InlineData(@"nested\folder\file.pdf", "file.pdf")] + [InlineData("nested/folder/file.pdf", "file.pdf")] + [InlineData("Annual Report 2024.pdf", "Annual Report 2024.pdf")] + public void SanitizeZipEntryName_StripsPathComponents(string friendlyName, string expected) + { + var result = CmsData.SanitizeZipEntryName(friendlyName, fallback: "fallback.bin"); + + Assert.Equal(expected, result); + Assert.DoesNotContain('\\', result); + Assert.DoesNotContain('/', result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SanitizeZipEntryName_EmptyFriendlyName_FallsBackToFilePath(string? friendlyName) + { + var result = CmsData.SanitizeZipEntryName(friendlyName, fallback: @"C:\storage\real-file.bin"); + + Assert.Equal("real-file.bin", result); + } + + [Theory] + [InlineData("..")] + [InlineData(".")] + [InlineData("dir/..")] + [InlineData(@"nested\.")] + [InlineData(". .")] + public void SanitizeZipEntryName_DotsOnly_FallsBackToFilePath(string friendlyName) + { + var result = CmsData.SanitizeZipEntryName(friendlyName, fallback: @"C:\storage\real-file.bin"); + + Assert.Equal("real-file.bin", result); + } + + #endregion + + #region BuildTempArchivePath + + [Fact] + public void BuildTempArchivePath_ReturnsPathUnderTempRoot() + { + var tempRoot = CreateIsolatedTempRoot(); + try + { + var path = CmsData.BuildTempArchivePath(tempRoot); + + var resolved = Path.GetFullPath(path); + var normalizedRoot = Path.GetFullPath(tempRoot); + + Assert.StartsWith( + normalizedRoot + Path.DirectorySeparatorChar, + resolved, + StringComparison.OrdinalIgnoreCase); + Assert.EndsWith(".zip", resolved, StringComparison.OrdinalIgnoreCase); + } + finally + { + Cleanup(tempRoot); + } + } + + [Fact] + public void BuildTempArchivePath_AcceptsRootWithTrailingSeparator() + { + var tempRoot = CreateIsolatedTempRoot(); + var withTrailing = tempRoot + Path.DirectorySeparatorChar; + try + { + var path = CmsData.BuildTempArchivePath(withTrailing); + + var resolved = Path.GetFullPath(path); + var normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(withTrailing)); + + Assert.StartsWith( + normalizedRoot + Path.DirectorySeparatorChar, + resolved, + StringComparison.OrdinalIgnoreCase); + } + finally + { + Cleanup(tempRoot); + } + } + + [Fact] + public void BuildTempArchivePath_GeneratesUniquePaths_OnSuccessiveCalls() + { + var tempRoot = CreateIsolatedTempRoot(); + try + { + var a = CmsData.BuildTempArchivePath(tempRoot); + var b = CmsData.BuildTempArchivePath(tempRoot); + + Assert.NotEqual(a, b); + } + finally + { + Cleanup(tempRoot); + } + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void BuildTempArchivePath_RejectsEmptyRoot(string tempRoot) + { + Assert.Throws(() => CmsData.BuildTempArchivePath(tempRoot)); + } + + [Fact] + public void BuildTempArchivePath_RejectsNullRoot() + { + Assert.Throws(() => CmsData.BuildTempArchivePath(null!)); + } + + #endregion + + #region Helpers + + private static string CreateIsolatedTempRoot() + { + var root = Path.Join(Path.GetTempPath(), "Viper-CMS-Test-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static void Cleanup(string root) + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + + #endregion +} diff --git a/web/Areas/CMS/Data/CMS.cs b/web/Areas/CMS/Data/CMS.cs index 6e0aeedc0..2193df69a 100644 --- a/web/Areas/CMS/Data/CMS.cs +++ b/web/Areas/CMS/Data/CMS.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Text.Json; using Viper.Areas.CMS.Models; +using Viper.Areas.CMS.Services; using Viper.Classes.SQLContext; using Viper.Classes.Utilities; using Viper.Models; @@ -442,14 +443,13 @@ public bool CheckFilePermission(CMSFile file) #region public IActionResult DownloadZip(Controller controller, string[] fileGUIDs, string fileName = "FileDownload.zip") public IActionResult DownloadZip(Controller controller, string[] fileGUIDs, string fileName = "FileDownload.zip") { - if (fileGUIDs.Length == 0 && fileName.Length == 0) + if (fileGUIDs.Length == 0) { - ArgumentNullException argumentNullException = new(nameof(fileGUIDs), "Missing fileGUIDs and file name parameters"); - throw argumentNullException; + return controller.BadRequest("Missing fileGUIDs parameter."); } - //only allow good filename characters - fileName = fileName.Replace(@"[^a-zA-Z0-9\.\-_ ]", ""); + var safeDownloadName = CmsFilePathSafety.SanitizeDownloadName(fileName); + List files = new(); AaudUser? currentUser = UserHelper.GetCurrentUser(); @@ -469,45 +469,55 @@ public IActionResult DownloadZip(Controller controller, string[] fileGUIDs, stri } } - // create a temp Zip file and populate it with the files - string tempFileName = CMS.GetRootFileFolder() + @"\" + DateTime.Now.Ticks + fileName; + if (files.Count == 0) + { + return controller.NotFound(); + } + + string tempFileName = CmsFilePathSafety.BuildTempArchivePath(CmsFilePathSafety.GetZipTempFolder()); - using (FileStream fs = System.IO.File.Open(tempFileName, FileMode.OpenOrCreate)) + try { - using ZipArchive archive = new(fs, ZipArchiveMode.Update); - foreach (var file in files) + using (FileStream fs = System.IO.File.Open(tempFileName, FileMode.Create)) + using (ZipArchive archive = new(fs, ZipArchiveMode.Create)) { - if (file.Encrypted && !string.IsNullOrEmpty(file.Key)) + foreach (var file in files) { - ZipArchiveEntry fileEntry = archive.CreateEntry(file.FriendlyName); - using StreamWriter writer = new(fileEntry.Open()); - byte[] filebytes = System.IO.File.ReadAllBytes(file.FilePath); - filebytes = DecryptFile(filebytes, file.Key); + var entryName = CmsFilePathSafety.SanitizeZipEntryName(file.FriendlyName, file.FilePath); - if (filebytes != null) + if (file.Encrypted && !string.IsNullOrEmpty(file.Key)) { - writer.BaseStream.Write(filebytes, 0, filebytes.Length); + ZipArchiveEntry fileEntry = archive.CreateEntry(entryName); + using var entryStream = fileEntry.Open(); + byte[] filebytes = System.IO.File.ReadAllBytes(file.FilePath); + filebytes = DecryptFile(filebytes, file.Key); + + entryStream.Write(filebytes, 0, filebytes.Length); + } + else + { + archive.CreateEntryFromFile(file.FilePath, entryName); } } - else + } + + byte[] bytes = System.IO.File.ReadAllBytes(tempFileName); + return controller.File(bytes, MimeTypes["zip"], safeDownloadName); + } + finally + { + // Best-effort cleanup: swallow filesystem exceptions so we don't + // mask an earlier exception from archive creation or the response. + try + { + if (System.IO.File.Exists(tempFileName)) { - archive.CreateEntryFromFile(file.FilePath, file.FriendlyName); + System.IO.File.Delete(tempFileName); } - } + catch (IOException) { /* ignored */ } + catch (UnauthorizedAccessException) { /* ignored */ } } - - // read the temp zip file then delete it - byte[] bytes = System.IO.File.ReadAllBytes(tempFileName); - if (bytes == null) - return controller.NotFound(); - - System.IO.File.Delete(tempFileName); - - string extension = "zip"; - - return controller.File(bytes, MimeTypes[extension.ToLower()], fileName); - } #endregion diff --git a/web/Areas/CMS/Services/CmsFilePathSafety.cs b/web/Areas/CMS/Services/CmsFilePathSafety.cs new file mode 100644 index 000000000..8f504746d --- /dev/null +++ b/web/Areas/CMS/Services/CmsFilePathSafety.cs @@ -0,0 +1,155 @@ +using System.Text.RegularExpressions; + +namespace Viper.Areas.CMS.Services +{ + /// + /// Path-safety primitives for CMS file download flows (VPR-138). + /// + /// Two surfaces: the static class is used by the legacy + /// CMS.DownloadZip call site (no DI plumbing); the + /// interface is the contract the + /// PLAN-CMS migration's IFileStorageService / new download + /// controller depends on. See PLAN-CMS.md §11.7. + /// + public static class CmsFilePathSafety + { + private const string DefaultDownloadName = "FileDownload.zip"; + + private static readonly HashSet ReservedWindowsNames = new(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + + private static readonly Regex SafeFileNameAllowList = new(@"[^a-zA-Z0-9._\- ]", RegexOptions.Compiled); + + /// + /// Returns a filename safe to use in a Content-Disposition response header. + /// Strips path components, applies an allow-list, rejects reserved Windows + /// device names, and guarantees a .zip suffix so the name matches + /// the application/zip MIME type. + /// + public static string SanitizeDownloadName(string? userInput) + { + if (string.IsNullOrWhiteSpace(userInput)) + { + return DefaultDownloadName; + } + + var fileNamePart = StripPathComponents(userInput); + var filtered = SafeFileNameAllowList.Replace(fileNamePart, string.Empty).Trim(); + + // Reject names that collapse to only dots/spaces: ".", ".." etc. would + // become "..zip" after the suffix step, which is traversal-shaped. + if (filtered.Trim('.', ' ').Length == 0) + { + return DefaultDownloadName; + } + + var stem = filtered; + var dotIndex = stem.IndexOf('.'); + if (dotIndex >= 0) + { + stem = stem[..dotIndex]; + } + if (ReservedWindowsNames.Contains(stem)) + { + return DefaultDownloadName; + } + + if (!filtered.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + filtered += ".zip"; + } + + return filtered; + } + + /// + /// Builds a per-request temp archive path under + /// using a server-generated GUID, and asserts the resolved path stays + /// inside that root. + /// + public static string BuildTempArchivePath(string tempRoot) + { + if (string.IsNullOrWhiteSpace(tempRoot)) + { + throw new ArgumentException("Temp root must be provided.", nameof(tempRoot)); + } + + var resolvedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(tempRoot)); + var candidate = Path.Join(resolvedRoot, Guid.NewGuid().ToString("N") + ".zip"); + var resolved = Path.GetFullPath(candidate); + + if (!resolved.StartsWith(resolvedRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Generated temp path escaped the configured root."); + } + + return resolved; + } + + /// + /// Dedicated temp directory for ZIP archives. Kept separate from the + /// CMS content root so transient archives never mix with managed + /// content, and never get served through content URLs. + /// + public static string GetZipTempFolder() + { + return Path.Join(Path.GetTempPath(), "Viper-CMS"); + } + + /// + /// Returns a safe ZIP entry name from a stored file's friendly name. + /// Defense in depth against ZIP-slip when the archive is extracted. + /// + public static string SanitizeZipEntryName(string? friendlyName, string fallback) + { + if (!string.IsNullOrWhiteSpace(friendlyName)) + { + var entry = StripPathComponents(friendlyName); + if (!string.IsNullOrWhiteSpace(entry) && entry.Trim('.', ' ').Length > 0) + { + return entry; + } + } + + return StripPathComponents(fallback); + } + + // Path.GetFileName only honors the host OS separator, so "\..\evil" + // leaks through unchanged on Linux runners. Normalize first. + private static string StripPathComponents(string input) + => Path.GetFileName(input.Replace('\\', '/')); + } + + /// + /// DI-injectable contract for . New code + /// (see PLAN-CMS.md §11.7) depends on this interface so the underlying + /// implementation can evolve without touching call sites. + /// + public interface ICmsFilePathSafety + { + string SanitizeDownloadName(string? userInput); + string BuildTempArchivePath(string tempRoot); + string GetZipTempFolder(); + string SanitizeZipEntryName(string? friendlyName, string fallback); + } + + /// + public sealed class CmsFilePathSafetyService : ICmsFilePathSafety + { + public string SanitizeDownloadName(string? userInput) => + CmsFilePathSafety.SanitizeDownloadName(userInput); + + public string BuildTempArchivePath(string tempRoot) => + CmsFilePathSafety.BuildTempArchivePath(tempRoot); + + public string GetZipTempFolder() => + CmsFilePathSafety.GetZipTempFolder(); + + public string SanitizeZipEntryName(string? friendlyName, string fallback) => + CmsFilePathSafety.SanitizeZipEntryName(friendlyName, fallback); + } +} diff --git a/web/Program.cs b/web/Program.cs index 5fee3318b..246623941 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -277,7 +277,8 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db "Viper.Areas.ClinicalScheduler.Validators", "Viper.Areas.Students.Services", "Viper.Areas.Curriculum.Services", - "Viper.Areas.Effort.Services" + "Viper.Areas.Effort.Services", + "Viper.Areas.CMS.Services" ) .Where(type => type.Name.EndsWith("Service") || type.Name.EndsWith("Validator"))) .UsingRegistrationStrategy(Scrutor.RegistrationStrategy.Skip) @@ -328,6 +329,10 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db var app = builder.Build(); + // Ensure the per-request CMS ZIP temp folder exists once at startup + // rather than on every download request. + Directory.CreateDirectory(Viper.Areas.CMS.Services.CmsFilePathSafety.GetZipTempFolder()); + // Add Content Security Policy. Skip for HealthChecks.UI paths - the bundled UI // uses inline scripts and data: fonts that our strict CSP would block. Those // paths are already IP-gated to trusted SVM admin subnets, so relaxing CSP @@ -415,44 +420,6 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db } - // In development, set up Vite proxy BEFORE rewrite rules so it can handle .ts/.js files - if (app.Environment.IsDevelopment()) - { - // Development: Proxy Vue.js assets to Vite dev server for hot module replacement (HMR) - // This middleware intercepts requests for Vue assets and forwards them to the Vite dev server - app.Use(async (context, next) => - { - if (ViteProxyHelpers.ShouldProxyToVite(context, VueAppNames)) - { - try - { - // Use the registered HttpClient from dependency injection - var httpClientFactory = context.RequestServices.GetRequiredService(); - var httpClient = httpClientFactory.CreateClient("ViteProxy"); - - // Build the Vite server URL and try to proxy directly - var viteUrl = ViteProxyHelpers.BuildViteUrl(context.Request.Path, context.Request.QueryString, VueAppNames); - var requestMessage = ViteProxyHelpers.CreateProxyRequest(context, viteUrl); - using var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); - - // Copy the response back to the client - await ViteProxyHelpers.CopyProxyResponse(context, response); - return; // Successfully proxied, don't continue to static files - } - catch (Exception ex) - { - var logger = context.RequestServices.GetRequiredService>(); - logger.LogDebug(ex, "Vite server not available, falling back to static files for {Path}", - Uri.EscapeDataString(context.Request.Path.Value ?? "unknown")); - // Fall through to static file serving - } - } - - // Continue to static file serving (either Vite not needed or not available) - await next(); - }); - } - var rewriteOptions = new RewriteOptions(); // Add redirects and rewrites for each SPA using centralized app names @@ -469,41 +436,78 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db rewriteOptions.AddRewrite($@"(?i)^{escapedAppName}", $"/2/vue/src/{lowerAppName}/index.html", true); } - app.UseRewriter(rewriteOptions); - - //for the vue src files, use directories in the url but serve index.html + // Default-file convention for /vue (legacy path). app.UseDefaultFiles(new DefaultFilesOptions { DefaultFileNames = new List { "index.html" }, FileProvider = new PhysicalFileProvider( - Path.Combine(builder.Environment.ContentRootPath, "wwwroot/vue")), + Path.Combine(builder.Environment.ContentRootPath, "wwwroot", "vue")), RequestPath = "/vue", RedirectToAppendTrailingSlash = true }); - // Static file serving configuration - // Serve built Vue files - in development proxy middleware runs first, - // in production these files are served directly - app.UseStaticFiles(new StaticFileOptions - { - FileProvider = new PhysicalFileProvider( - Path.Combine(builder.Environment.ContentRootPath, "wwwroot/vue")), - RequestPath = "/2/vue" - }); - - // Serve other static files + // General static files (favicon, /css, /js, /images, etc.). app.UseStaticFiles(); - // Add sitemap middleware after static file handling app.UseSitemapMiddleware(); - // apply settings define earlier + // Routing first so subsequent middleware can defer to a matched MVC endpoint. app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseCookiePolicy(); app.UseSession(); + // SPA shell serving — Vue app prefixes like /CMS, /Effort, etc. + // Only runs when no MVC controller endpoint claimed the path, so attribute-routed + // legacy endpoints (e.g. /CMS/Files → CMSController.Files) reach the controller + // instead of being rewritten to the SPA shell. + app.UseWhen( + ctx => ctx.GetEndpoint() is null, + branch => + { + if (app.Environment.IsDevelopment()) + { + // Dev: proxy Vue assets and SPA routes to the Vite dev server (HMR). + branch.Use(async (context, next) => + { + if (ViteProxyHelpers.ShouldProxyToVite(context, VueAppNames)) + { + try + { + var httpClientFactory = context.RequestServices.GetRequiredService(); + var httpClient = httpClientFactory.CreateClient("ViteProxy"); + + var viteUrl = ViteProxyHelpers.BuildViteUrl(context.Request.Path, context.Request.QueryString, VueAppNames); + var requestMessage = ViteProxyHelpers.CreateProxyRequest(context, viteUrl); + using var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted); + + await ViteProxyHelpers.CopyProxyResponse(context, response); + return; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + var logger = context.RequestServices.GetRequiredService>(); + logger.LogDebug(ex, "Vite server not available, falling back to static files for {Path}", + Uri.EscapeDataString(context.Request.Path.Value ?? "unknown")); + } + } + + await next(); + }); + } + + // Prod (and dev fallback): rewrite SPA routes to the built SPA shell, + // then serve the static file from wwwroot/vue. + branch.UseRewriter(rewriteOptions); + branch.UseStaticFiles(new StaticFileOptions + { + FileProvider = new PhysicalFileProvider( + Path.Combine(builder.Environment.ContentRootPath, "wwwroot", "vue")), + RequestPath = "/2/vue" + }); + }); + // All health-check pipeline wiring lives in HealthCheckExtensions. app.UseViperHealthChecks();