// Copyright (c) Microsoft. All rights reserved. using System.Collections.Frozen; using System.IO.Compression; using System.Reflection; using System.Security.Cryptography; using System.Text.RegularExpressions; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; namespace Microsoft.Agents.AI.DevUI; /// /// Handler that serves embedded DevUI resource files from the 'resources' directory. /// internal sealed partial class DevUIMiddleware { [GeneratedRegex(@"[\r\n]+")] private static partial Regex NewlineRegex(); private const string GZipEncodingValue = "gzip"; private static readonly StringValues s_gzipEncodingHeader = new(GZipEncodingValue); private static readonly Assembly s_assembly = typeof(DevUIMiddleware).Assembly; private static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new(); private static readonly StringValues s_cacheControl = new(new CacheControlHeaderValue() { NoCache = true, NoStore = true, }.ToString()); private readonly ILogger _logger; private readonly FrozenDictionary _resourceCache; private readonly string _basePath; /// /// Initializes a new instance of the class. /// /// The logger instance. /// The base path where DevUI is mounted. public DevUIMiddleware(ILogger logger, string basePath) { ArgumentNullException.ThrowIfNull(logger); ArgumentException.ThrowIfNullOrEmpty(basePath); this._logger = logger; this._basePath = basePath.TrimEnd('/'); // Build resource cache var resourceNamePrefix = $"{s_assembly.GetName().Name}.resources."; this._resourceCache = s_assembly .GetManifestResourceNames() .Where(p => p.StartsWith(resourceNamePrefix, StringComparison.Ordinal)) .ToFrozenDictionary( p => p[resourceNamePrefix.Length..].Replace('.', '/'), CreateResourceEntry, StringComparer.OrdinalIgnoreCase); } /// /// Handles an HTTP request for DevUI resources. /// /// The HTTP context. public async Task HandleRequestAsync(HttpContext context) { var path = context.Request.Path.Value; if (path == null) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; } // If requesting the base path without a trailing slash, redirect to include it // This ensures relative URLs in the HTML work correctly if (string.Equals(path, this._basePath, StringComparison.OrdinalIgnoreCase) && !path.EndsWith('/')) { var redirectUrl = this._basePath + "/"; if (context.Request.QueryString.HasValue) { redirectUrl += context.Request.QueryString.Value; } context.Response.StatusCode = StatusCodes.Status301MovedPermanently; context.Response.Headers.Location = redirectUrl; // CodeQL [SM04598] justification: The redirect URL is constructed from a server-configured base path (_basePath), not user input. The query string is only appended as parameters and cannot change the redirect destination since this is a relative URL. if (this._logger.IsEnabled(LogLevel.Debug)) { this._logger.LogDebug("Redirecting {OriginalPath} to {RedirectUrl}", NewlineRegex().Replace(path, ""), NewlineRegex().Replace(redirectUrl, "")); } return; } // Remove the base path to get the resource path var resourcePath = path.StartsWith(this._basePath, StringComparison.OrdinalIgnoreCase) ? path.Substring(this._basePath.Length).TrimStart('/') : path.TrimStart('/'); // If requesting the base path, serve index.html if (string.IsNullOrEmpty(resourcePath)) { resourcePath = "index.html"; } // Try to serve the embedded resource if (await this.TryServeResourceAsync(context, resourcePath).ConfigureAwait(false)) { return; } // If resource not found, try serving index.html for client-side routing if (!resourcePath.Contains('.', StringComparison.Ordinal) || resourcePath.EndsWith('/')) { if (await this.TryServeResourceAsync(context, "index.html").ConfigureAwait(false)) { return; } } // Resource not found context.Response.StatusCode = StatusCodes.Status404NotFound; } private async Task TryServeResourceAsync(HttpContext context, string resourcePath) { try { if (!this._resourceCache.TryGetValue(resourcePath.Replace('.', '/'), out var cacheEntry)) { if (this._logger.IsEnabled(LogLevel.Debug)) { this._logger.LogDebug("Embedded resource not found: {ResourcePath}", resourcePath); } return false; } var response = context.Response; // Check if client has cached version if (context.Request.Headers.IfNoneMatch == cacheEntry.ETag) { response.StatusCode = StatusCodes.Status304NotModified; if (this._logger.IsEnabled(LogLevel.Debug)) { this._logger.LogDebug("Resource not modified (304): {ResourcePath}", resourcePath); } return true; } var responseHeaders = response.Headers; byte[] content; bool serveCompressed; if (cacheEntry.CompressedContent is not null && IsGZipAccepted(context.Request)) { serveCompressed = true; responseHeaders.ContentEncoding = s_gzipEncodingHeader; responseHeaders.ContentLength = cacheEntry.CompressedContent.Length; content = cacheEntry.CompressedContent; } else { serveCompressed = false; responseHeaders.ContentLength = cacheEntry.DecompressedContent!.Length; content = cacheEntry.DecompressedContent; } responseHeaders.CacheControl = s_cacheControl; responseHeaders.ContentType = cacheEntry.ContentType; responseHeaders.ETag = cacheEntry.ETag; await response.Body.WriteAsync(content, context.RequestAborted).ConfigureAwait(false); if (this._logger.IsEnabled(LogLevel.Debug)) { this._logger.LogDebug("Served embedded resource: {ResourcePath} (compressed: {Compressed})", resourcePath, serveCompressed); } return true; } catch (Exception ex) { if (this._logger.IsEnabled(LogLevel.Error)) { this._logger.LogError(ex, "Error serving embedded resource: {ResourcePath}", resourcePath); } return false; } } private static bool IsGZipAccepted(HttpRequest httpRequest) { if (httpRequest.GetTypedHeaders().AcceptEncoding is not { Count: > 0 } acceptEncoding) { return false; } for (int i = 0; i < acceptEncoding.Count; i++) { var encoding = acceptEncoding[i]; if (encoding.Quality is not 0 && string.Equals(encoding.Value.Value, GZipEncodingValue, StringComparison.OrdinalIgnoreCase)) { return true; } } return false; } private static ResourceEntry CreateResourceEntry(string resourceName) { using var resourceStream = s_assembly.GetManifestResourceStream(resourceName)!; using var decompressedContent = new MemoryStream(); // Read and cache the original resource content resourceStream.CopyTo(decompressedContent); var decompressedArray = decompressedContent.ToArray(); // Compress the content using var compressedContent = new MemoryStream(); using (var gzip = new GZipStream(compressedContent, CompressionMode.Compress, leaveOpen: true)) { // This is a synchronous write to a memory stream. // There is no benefit to asynchrony here. gzip.Write(decompressedArray); } // Only use compression if it actually reduces size byte[]? compressedArray = compressedContent.Length < decompressedArray.Length ? compressedContent.ToArray() : null; var hash = SHA256.HashData(compressedArray ?? decompressedArray); var eTag = $"\"{Convert.ToBase64String(hash)}\""; // Determine content type from resource name var contentType = s_contentTypeProvider.TryGetContentType(resourceName, out var ct) ? ct : "application/octet-stream"; return new ResourceEntry(resourceName, decompressedArray, compressedArray, eTag, contentType); } private sealed class ResourceEntry(string resourceName, byte[] decompressedContent, byte[]? compressedContent, string eTag, string contentType) { public byte[]? CompressedContent { get; } = compressedContent; public string ContentType { get; } = contentType; public byte[] DecompressedContent { get; } = decompressedContent; public string ETag { get; } = eTag; public string ResourceName { get; } = resourceName; } }