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();