duplicati/Duplicati/WebserverCore/Middlewares/StaticFilesMiddleware.cs

238 lines
10 KiB
C#

// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Text.RegularExpressions;
using Duplicati.Library.AutoUpdater;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.FileProviders.Physical;
using Microsoft.Net.Http.Headers;
namespace Duplicati.WebserverCore.Middlewares;
public static class StaticFilesExtensions
{
private static readonly HashSet<string> _nonCachePaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"/",
"/index.html",
"/login.html",
"/signin.html"
};
private static readonly CacheControlHeaderValue _noCache = new CacheControlHeaderValue
{
NoCache = true,
NoStore = true,
MustRevalidate = true
};
private static readonly CacheControlHeaderValue _allowCache = new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromDays(7)
};
private const string FORWARDED_PREFIX_HEADER = "X-Forwarded-Prefix";
private const string DEFAULT_FORWARDED_PREFIX = "/ngclient";
private sealed record SpaConfig(string Prefix, string FileContent, string BasePath);
private static readonly Regex _baseHrefRegex = new Regex(
@"(<base\b[^>]*?\bhref\s*=\s*)(['""])\s*/\s*(?=\2)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex _headInjectRegex = new Regex(
@"(</head\s*>)",
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline);
private static string PatchIndexContent(string fileContent, string prefix)
{
if (!prefix.EndsWith("/"))
prefix += "/";
var headContent = string.Empty;
if (!string.IsNullOrWhiteSpace(AutoUpdateSettings.CustomCssFilePath))
headContent += $"<link rel=\"stylesheet\" href=\"oem-custom.css\" />";
if (!string.IsNullOrWhiteSpace(AutoUpdateSettings.CustomJsFilePath))
headContent += $"<script src=\"oem-custom.js\"></script>";
fileContent = _headInjectRegex.Replace(fileContent, headContent + "</head>");
return _baseHrefRegex.Replace(fileContent, match =>
{
var quote = match.Groups[2].Value;
return $"{match.Groups[1].Value}{quote}{prefix}";
});
}
public static IApplicationBuilder UseDefaultStaticFiles(this WebApplication app, string webroot, IEnumerable<string> spaPaths)
{
var fileTypeMappings = new FileExtensionContentTypeProvider()
{
Mappings = {
["htc"] = "text/x-component",
["json"] = "application/json",
["map"] = "application/json",
["htm"] = "text/html; charset=utf-8",
["html"] = "text/html; charset=utf-8",
["hbs"] = "application/x-handlebars-template",
["woff"] = "application/font-woff",
["woff2"] = "application/font-woff"
}
};
var prefixHandlerMap = new List<SpaConfig>();
var missingFile = new FileInfo(Path.Combine(webroot, "missing-spa.html"));
var customCssFile = string.IsNullOrWhiteSpace(AutoUpdateSettings.CustomCssFilePath)
? null
: new FileInfo(AutoUpdateSettings.CustomCssFilePath!);
var customJsFile = string.IsNullOrWhiteSpace(AutoUpdateSettings.CustomJsFilePath)
? null
: new FileInfo(AutoUpdateSettings.CustomJsFilePath!);
foreach (var prefix in spaPaths)
{
var basepath = Path.Combine(webroot, prefix.TrimStart('/'));
if (!Directory.Exists(basepath))
continue;
var file = Path.Combine(basepath, "index.html");
var fi = new FileInfo(file);
if (fi.Exists)
{
prefixHandlerMap.Add(new SpaConfig(prefix, File.ReadAllText(fi.FullName), basepath));
}
#if DEBUG
else
{
// Install from NPM in debug mode for easier development
var spaConfig = NpmSpaHelper.ProbeForNpmSpa(basepath);
if (spaConfig != null)
prefixHandlerMap.Add(new SpaConfig(prefix, File.ReadAllText(spaConfig.IndexFile.FullName), spaConfig.BasePath));
else if (spaConfig == null && missingFile.Exists)
prefixHandlerMap.Add(new SpaConfig(prefix, File.ReadAllText(missingFile.FullName), basepath));
}
#endif
}
if (prefixHandlerMap.Any())
{
prefixHandlerMap = prefixHandlerMap.OrderByDescending(p => p.Prefix.Length).ToList();
app.Use(async (context, next) =>
{
await next();
// Not found only
if (context.Response.StatusCode != 404 || context.Response.HasStarted)
return;
// Check if we can use the path
var path = context.Request.Path;
if (!path.HasValue)
return;
// Check if the path is a SPA path
var spaConfig = prefixHandlerMap.FirstOrDefault(p => path.Value.StartsWith(p.Prefix));
if (spaConfig == null)
return;
if (string.IsNullOrEmpty(Path.GetExtension(path)) || path.Value.EndsWith("/index.html"))
{
// Serve the index file
context.Response.ContentType = "text/html";
context.Response.StatusCode = 200;
var indexContent = spaConfig.FileContent;
#if DEBUG
// In debug mode, we re-read the index file to ensure we have the latest content for debugging
indexContent = File.ReadAllText(Path.Combine(spaConfig.BasePath, "index.html"));
#endif
var forwardedPrefix = context.Request.Headers[FORWARDED_PREFIX_HEADER].FirstOrDefault();
if (string.IsNullOrEmpty(forwardedPrefix))
forwardedPrefix = DEFAULT_FORWARDED_PREFIX;
await context.Response.WriteAsync(PatchIndexContent(indexContent, forwardedPrefix), context.RequestAborted);
await context.Response.CompleteAsync();
}
else if (path.Value.EndsWith("/oem-custom.css") && (customCssFile?.Exists ?? false))
{
// Serve the custom CSS file
context.Response.ContentType = "text/css";
context.Response.StatusCode = 200;
await context.Response.SendFileAsync(new PhysicalFileInfo(customCssFile));
await context.Response.CompleteAsync();
}
else if (path.Value.EndsWith("/oem-custom.js") && (customJsFile?.Exists ?? false))
{
// Serve the custom JS file
context.Response.ContentType = "application/javascript";
context.Response.StatusCode = 200;
await context.Response.SendFileAsync(new PhysicalFileInfo(customJsFile));
await context.Response.CompleteAsync();
}
else
{
// Serve the static file
var file = new FileInfo(Path.Combine(spaConfig.BasePath, path.Value.Substring(spaConfig.Prefix.Length).TrimStart('/')));
if (file.FullName.StartsWith(spaConfig.BasePath, Library.Utility.Utility.ClientFilenameStringComparison) && file.Exists && fileTypeMappings.Mappings.TryGetValue(Path.GetExtension(file.Extension), out var contentType))
{
context.Response.ContentType = contentType;
context.Response.StatusCode = 200;
await context.Response.SendFileAsync(new PhysicalFileInfo(file));
await context.Response.CompleteAsync();
}
}
});
}
var fileProvider = new PhysicalFileProvider(Path.GetFullPath(webroot));
var defaultFiles = GetDefaultFiles(fileProvider);
app.UseDefaultFiles(defaultFiles);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = fileProvider,
RequestPath = "",
OnPrepareResponse = (context) =>
{
var headers = context.Context.Response.GetTypedHeaders();
var path = context.Context.Request.Path.Value ?? string.Empty;
headers.CacheControl =
(path.EndsWith("/index.html") || _nonCachePaths.Contains(path))
? _noCache
: _allowCache;
},
ContentTypeProvider = fileTypeMappings
});
return app;
}
private static DefaultFilesOptions GetDefaultFiles(PhysicalFileProvider fileProvider)
{
var defaultFiles = new DefaultFilesOptions
{
FileProvider = fileProvider
};
defaultFiles.DefaultFileNames.Clear();
defaultFiles.DefaultFileNames.Add("index.html");
return defaultFiles;
}
}