[backend/razor] Add transparent decompression hook to MapStaticAssets, replacing the old BlazorFrameworkFiles hook
This commit is contained in:
parent
6b4e372973
commit
1e5c033fb1
8 changed files with 338 additions and 217 deletions
|
@ -1,212 +0,0 @@
|
||||||
using System.IO.Compression;
|
|
||||||
using Microsoft.AspNetCore.StaticFiles;
|
|
||||||
using Microsoft.Extensions.FileProviders;
|
|
||||||
using Microsoft.Extensions.Primitives;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
|
||||||
using HeaderNames = AngleSharp.Io.HeaderNames;
|
|
||||||
|
|
||||||
namespace Iceshrimp.Backend.Core.Extensions;
|
|
||||||
|
|
||||||
public static class WebApplicationBlazorFrameworkExtensions
|
|
||||||
{
|
|
||||||
private static readonly string? DotnetModifiableAssemblies =
|
|
||||||
GetNonEmptyEnvironmentVariableValue("DOTNET_MODIFIABLE_ASSEMBLIES");
|
|
||||||
|
|
||||||
private static readonly string? AspnetcoreBrowserTools =
|
|
||||||
GetNonEmptyEnvironmentVariableValue("__ASPNETCORE_BROWSER_TOOLS");
|
|
||||||
|
|
||||||
private static string? GetNonEmptyEnvironmentVariableValue(string name)
|
|
||||||
{
|
|
||||||
var environmentVariable = Environment.GetEnvironmentVariable(name);
|
|
||||||
return environmentVariable is not { Length: > 0 } ? null : environmentVariable;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddMapping(
|
|
||||||
this FileExtensionContentTypeProvider provider,
|
|
||||||
string name,
|
|
||||||
string mimeType
|
|
||||||
)
|
|
||||||
{
|
|
||||||
provider.Mappings.TryAdd(name, mimeType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static StaticFileOptions CreateStaticFilesOptions(IFileProvider webRootFileProvider)
|
|
||||||
{
|
|
||||||
var staticFilesOptions = new StaticFileOptions { FileProvider = webRootFileProvider };
|
|
||||||
var contentTypeProvider = new FileExtensionContentTypeProvider();
|
|
||||||
|
|
||||||
contentTypeProvider.AddMapping(".dll", "application/octet-stream");
|
|
||||||
contentTypeProvider.AddMapping(".webcil", "application/octet-stream");
|
|
||||||
contentTypeProvider.AddMapping(".pdb", "application/octet-stream");
|
|
||||||
contentTypeProvider.AddMapping(".br", "application/octet-stream");
|
|
||||||
contentTypeProvider.AddMapping(".dat", "application/octet-stream");
|
|
||||||
contentTypeProvider.AddMapping(".blat", "application/octet-stream");
|
|
||||||
|
|
||||||
staticFilesOptions.ContentTypeProvider = contentTypeProvider;
|
|
||||||
staticFilesOptions.OnPrepareResponse = (Action<StaticFileResponseContext>)(fileContext =>
|
|
||||||
{
|
|
||||||
fileContext.Context.Response.Headers.Append(HeaderNames.CacheControl, (StringValues)"no-cache");
|
|
||||||
var path = fileContext.Context.Request.Path;
|
|
||||||
var extension = Path.GetExtension(path.Value);
|
|
||||||
if (!string.Equals(extension, ".gz") && !string.Equals(extension, ".br"))
|
|
||||||
return;
|
|
||||||
var withoutExtension = Path.GetFileNameWithoutExtension(path.Value);
|
|
||||||
if (withoutExtension == null ||
|
|
||||||
!contentTypeProvider.TryGetContentType(withoutExtension, out var contentType))
|
|
||||||
return;
|
|
||||||
fileContext.Context.Response.ContentType = contentType;
|
|
||||||
});
|
|
||||||
return staticFilesOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IApplicationBuilder UseBlazorFrameworkFilesWithTransparentDecompression(this IApplicationBuilder app)
|
|
||||||
{
|
|
||||||
var webHostEnvironment = app.ApplicationServices.GetRequiredService<IWebHostEnvironment>();
|
|
||||||
var options = CreateStaticFilesOptions(webHostEnvironment.WebRootFileProvider);
|
|
||||||
|
|
||||||
app.MapWhen(Predicate, subBuilder =>
|
|
||||||
{
|
|
||||||
subBuilder.Use(async (context, next) =>
|
|
||||||
{
|
|
||||||
context.Response.Headers.Append("Blazor-Environment", webHostEnvironment.EnvironmentName);
|
|
||||||
if (AspnetcoreBrowserTools != null)
|
|
||||||
context.Response.Headers.Append("ASPNETCORE-BROWSER-TOOLS", AspnetcoreBrowserTools);
|
|
||||||
if (DotnetModifiableAssemblies != null)
|
|
||||||
context.Response.Headers.Append("DOTNET-MODIFIABLE-ASSEMBLIES",
|
|
||||||
DotnetModifiableAssemblies);
|
|
||||||
await next(context);
|
|
||||||
});
|
|
||||||
subBuilder.UseMiddleware<ContentEncodingNegotiator>();
|
|
||||||
subBuilder.UseStaticFiles(options);
|
|
||||||
subBuilder.Use(async (HttpContext context, RequestDelegate _) =>
|
|
||||||
{
|
|
||||||
context.Response.StatusCode = 404;
|
|
||||||
await context.Response.StartAsync();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return app;
|
|
||||||
|
|
||||||
bool Predicate(HttpContext ctx) => ctx.Request.Path.StartsWithSegments(new PathString(), out var remaining) &&
|
|
||||||
remaining.StartsWithSegments((PathString)"/_framework") &&
|
|
||||||
!remaining.StartsWithSegments((PathString)"/_framework/blazor.server.js") &&
|
|
||||||
!remaining.StartsWithSegments((PathString)"/_framework/blazor.web.js");
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ContentEncodingNegotiator(RequestDelegate next, IWebHostEnvironment webHostEnvironment)
|
|
||||||
{
|
|
||||||
private static readonly StringSegment[] PreferredEncodings = ["br", "gzip"];
|
|
||||||
|
|
||||||
private static readonly Dictionary<StringSegment, string> EncodingExtensionMap =
|
|
||||||
new(StringSegmentComparer.OrdinalIgnoreCase) { ["br"] = ".br", ["gzip"] = ".gz" };
|
|
||||||
|
|
||||||
public Task InvokeAsync(HttpContext context)
|
|
||||||
{
|
|
||||||
NegotiateEncoding(context);
|
|
||||||
return HookTransparentDecompression(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HookTransparentDecompression(HttpContext ctx)
|
|
||||||
{
|
|
||||||
if (ctx.Response.Headers.ContentEncoding.Count != 0 ||
|
|
||||||
!ResourceExists(ctx, ".br") ||
|
|
||||||
ResourceExists(ctx, ""))
|
|
||||||
{
|
|
||||||
await next(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var responseStream = ctx.Response.Body;
|
|
||||||
using var tempStream = new MemoryStream();
|
|
||||||
await using var brotliStream = new BrotliStream(tempStream, CompressionMode.Decompress);
|
|
||||||
|
|
||||||
ctx.Response.Body = tempStream;
|
|
||||||
ctx.Request.Path = (PathString)(ctx.Request.Path + ".br");
|
|
||||||
|
|
||||||
ctx.Response.OnStarting(() =>
|
|
||||||
{
|
|
||||||
ctx.Response.Headers.ContentLength = null;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
});
|
|
||||||
|
|
||||||
await next(ctx);
|
|
||||||
|
|
||||||
tempStream.Seek(0, SeekOrigin.Begin);
|
|
||||||
await brotliStream.CopyToAsync(responseStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void NegotiateEncoding(HttpContext context)
|
|
||||||
{
|
|
||||||
var acceptEncoding = context.Request.Headers.AcceptEncoding;
|
|
||||||
if (StringValues.IsNullOrEmpty(acceptEncoding) ||
|
|
||||||
!StringWithQualityHeaderValue.TryParseList(acceptEncoding, out var parsedValues) ||
|
|
||||||
parsedValues.Count == 0)
|
|
||||||
return;
|
|
||||||
var stringSegment1 = StringSegment.Empty;
|
|
||||||
var num = 0.0;
|
|
||||||
foreach (var encoding in parsedValues)
|
|
||||||
{
|
|
||||||
var stringSegment2 = encoding.Value;
|
|
||||||
var valueOrDefault = encoding.Quality.GetValueOrDefault(1.0);
|
|
||||||
if (!(valueOrDefault >= double.Epsilon) || !(valueOrDefault >= num)) continue;
|
|
||||||
|
|
||||||
if (Math.Abs(valueOrDefault - num) < 0.001)
|
|
||||||
{
|
|
||||||
stringSegment1 = PickPreferredEncoding(context, stringSegment1, encoding);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (EncodingExtensionMap.TryGetValue(stringSegment2, out var extension) &&
|
|
||||||
ResourceExists(context, extension))
|
|
||||||
{
|
|
||||||
stringSegment1 = stringSegment2;
|
|
||||||
num = valueOrDefault;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (StringSegment.Equals("*", stringSegment2, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
stringSegment1 = PickPreferredEncoding(context, new StringSegment(), encoding);
|
|
||||||
num = valueOrDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!StringSegment.Equals("identity", stringSegment2, StringComparison.OrdinalIgnoreCase))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
stringSegment1 = StringSegment.Empty;
|
|
||||||
num = valueOrDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!EncodingExtensionMap.TryGetValue(stringSegment1, out var str))
|
|
||||||
return;
|
|
||||||
|
|
||||||
context.Request.Path += str;
|
|
||||||
context.Response.Headers.ContentEncoding = stringSegment1.Value;
|
|
||||||
context.Response.Headers.Append(HeaderNames.Vary, HeaderNames.ContentEncoding);
|
|
||||||
return;
|
|
||||||
|
|
||||||
StringSegment PickPreferredEncoding(
|
|
||||||
HttpContext innerContext,
|
|
||||||
StringSegment selectedEncoding,
|
|
||||||
StringWithQualityHeaderValue encoding
|
|
||||||
)
|
|
||||||
{
|
|
||||||
foreach (var preferredEncoding in PreferredEncodings)
|
|
||||||
{
|
|
||||||
if (preferredEncoding == selectedEncoding)
|
|
||||||
return selectedEncoding;
|
|
||||||
if ((preferredEncoding == encoding.Value || encoding.Value == "*") &&
|
|
||||||
ResourceExists(innerContext, EncodingExtensionMap[preferredEncoding]))
|
|
||||||
return preferredEncoding;
|
|
||||||
}
|
|
||||||
|
|
||||||
return StringSegment.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool ResourceExists(HttpContext context, string extension)
|
|
||||||
{
|
|
||||||
return webHostEnvironment.WebRootFileProvider.GetFileInfo(context.Request.Path + extension).Exists;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
using System.IO.Compression;
|
||||||
|
using Microsoft.AspNetCore.StaticAssets;
|
||||||
|
|
||||||
|
namespace Iceshrimp.Backend.Core.Extensions;
|
||||||
|
|
||||||
|
public static class WebApplicationStaticAssetsExtensions
|
||||||
|
{
|
||||||
|
public static void MapStaticAssetsWithTransparentDecompression(this WebApplication app)
|
||||||
|
{
|
||||||
|
app.MapStaticAssets()
|
||||||
|
.Finally(builder =>
|
||||||
|
{
|
||||||
|
var @delegate = builder.RequestDelegate;
|
||||||
|
builder.RequestDelegate = async ctx =>
|
||||||
|
{
|
||||||
|
HookTransparentDecompression(ctx);
|
||||||
|
if (@delegate?.Invoke(ctx) is { } task)
|
||||||
|
await task;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void HookTransparentDecompression(HttpContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.GetEndpoint()?.Metadata.GetMetadata<StaticAssetDescriptor>() is not { } descriptor) return;
|
||||||
|
if (descriptor.AssetPath == descriptor.Route) return;
|
||||||
|
if (!descriptor.AssetPath.EndsWith(".br")) return;
|
||||||
|
if (descriptor.Selectors is not []) return;
|
||||||
|
|
||||||
|
var body = ctx.Response.Body;
|
||||||
|
var compressed = new MemoryStream();
|
||||||
|
ctx.Response.Body = compressed;
|
||||||
|
|
||||||
|
ctx.Response.OnStarting(async () =>
|
||||||
|
{
|
||||||
|
int? length = null;
|
||||||
|
var desc = descriptor.Properties.FirstOrDefault(p => p.Name == "Uncompressed-Length")?.Value;
|
||||||
|
if (int.TryParse(desc, out var parsed))
|
||||||
|
length = parsed;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ctx.Response.Headers.ContentLength = length;
|
||||||
|
ctx.Response.Headers.ContentEncoding = "plain";
|
||||||
|
|
||||||
|
await using var brotli = new BrotliStream(compressed, CompressionMode.Decompress);
|
||||||
|
compressed.Seek(0, SeekOrigin.Begin);
|
||||||
|
await brotli.CopyToAsync(body);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await compressed.DisposeAsync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,7 +7,10 @@
|
||||||
<UseCurrentRuntimeIdentifier>true</UseCurrentRuntimeIdentifier>
|
<UseCurrentRuntimeIdentifier>true</UseCurrentRuntimeIdentifier>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Import Project="..\Iceshrimp.Build\Iceshrimp.Build.props"/>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Iceshrimp.Build\Iceshrimp.Build.csproj" PrivateAssets="all" IncludeAssets="build" />
|
||||||
<ProjectReference Include="..\Iceshrimp.Frontend\Iceshrimp.Frontend.csproj" />
|
<ProjectReference Include="..\Iceshrimp.Frontend\Iceshrimp.Frontend.csproj" />
|
||||||
<ProjectReference Include="..\Iceshrimp.Parsing\Iceshrimp.Parsing.fsproj" />
|
<ProjectReference Include="..\Iceshrimp.Parsing\Iceshrimp.Parsing.fsproj" />
|
||||||
<ProjectReference Include="..\Iceshrimp.Shared\Iceshrimp.Shared.csproj" />
|
<ProjectReference Include="..\Iceshrimp.Shared\Iceshrimp.Shared.csproj" />
|
||||||
|
@ -45,9 +48,9 @@
|
||||||
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||||
<PackageReference Include="Ulid" Version="1.3.4" />
|
<PackageReference Include="Ulid" Version="1.3.4" />
|
||||||
<PackageReference Include="Iceshrimp.AssemblyUtils" Version="1.0.0" />
|
<PackageReference Include="Iceshrimp.AssemblyUtils" Version="1.0.1" />
|
||||||
<PackageReference Include="Iceshrimp.MimeTypes" Version="1.0.0" />
|
<PackageReference Include="Iceshrimp.MimeTypes" Version="1.0.1" />
|
||||||
<PackageReference Include="Iceshrimp.WebPush" Version="2.0.0" />
|
<PackageReference Include="Iceshrimp.WebPush" Version="2.1.0" />
|
||||||
<PackageReference Include="NetVips" Version="3.0.0" />
|
<PackageReference Include="NetVips" Version="3.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
@ -90,4 +93,12 @@
|
||||||
<Delete Files="@(FilesToClean->Exists())" />
|
<Delete Files="@(FilesToClean->Exists())" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
|
<!-- This runs a task that rewrites the static asset endpoints JSON file to enable transparent decompression. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<ManifestFiles Include="$(PublishDir)\Iceshrimp.Backend.staticwebassets.endpoints.json"/>
|
||||||
|
</ItemGroup>
|
||||||
|
<Target Name="RewriteStaticAssetManifest" AfterTargets="KeepOnlyBrotliCompressedStaticAssets">
|
||||||
|
<RewriteStaticAssetManifest ManifestFiles="@(ManifestFiles)" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -59,14 +59,13 @@ app.UseResponseCompression();
|
||||||
app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedProto });
|
app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedProto });
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseSwaggerWithOptions();
|
app.UseSwaggerWithOptions();
|
||||||
app.UseBlazorFrameworkFilesWithTransparentDecompression();
|
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
|
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) });
|
||||||
app.UseCustomMiddleware();
|
app.UseCustomMiddleware();
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssetsWithTransparentDecompression();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapFallbackToController("/api/{**slug}", "FallbackAction", "Fallback").WithOrder(int.MaxValue - 3);
|
app.MapFallbackToController("/api/{**slug}", "FallbackAction", "Fallback").WithOrder(int.MaxValue - 3);
|
||||||
app.MapHub<StreamingHub>("/hubs/streaming");
|
app.MapHub<StreamingHub>("/hubs/streaming");
|
||||||
|
|
14
Iceshrimp.Build/Iceshrimp.Build.csproj
Normal file
14
Iceshrimp.Build/Iceshrimp.Build.csproj
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Build.Framework" Version="17.12.6" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="17.12.6" PrivateAssets="all" ExcludeAssets="Runtime" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
8
Iceshrimp.Build/Iceshrimp.Build.props
Normal file
8
Iceshrimp.Build/Iceshrimp.Build.props
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup>
|
||||||
|
<CustomTasksAssembly>$(MSBuildThisFileDirectory)\bin\Release\net9.0\$(MSBuildThisFileName).dll</CustomTasksAssembly>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<UsingTask TaskName="Iceshrimp.Build.Tasks.RewriteStaticAssetManifest" AssemblyFile="$(CustomTasksAssembly)" />
|
||||||
|
</Project>
|
234
Iceshrimp.Build/RewriteStaticAssetManifestTask.cs
Normal file
234
Iceshrimp.Build/RewriteStaticAssetManifestTask.cs
Normal file
|
@ -0,0 +1,234 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Build.Framework;
|
||||||
|
|
||||||
|
// ReSharper disable once CheckNamespace
|
||||||
|
namespace Iceshrimp.Build.Tasks;
|
||||||
|
|
||||||
|
public class RewriteStaticAssetManifest : Microsoft.Build.Utilities.Task
|
||||||
|
{
|
||||||
|
public static void FixupFile(string manifestPath)
|
||||||
|
{
|
||||||
|
var parsed = Parse(manifestPath);
|
||||||
|
var @fixed = Fixup(manifestPath, parsed);
|
||||||
|
Write(manifestPath, @fixed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Required]
|
||||||
|
public required ITaskItem[] ManifestFiles { get; set; }
|
||||||
|
|
||||||
|
public override bool Execute()
|
||||||
|
{
|
||||||
|
foreach (var item in ManifestFiles) FixupFile(item.ItemSpec);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StaticAssetsManifest Parse(string manifestPath)
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(manifestPath);
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var content = reader.ReadToEnd();
|
||||||
|
|
||||||
|
// @formatter:off
|
||||||
|
var result = JsonSerializer.Deserialize<StaticAssetsManifest>(content) ??
|
||||||
|
throw new InvalidOperationException($"The static resources manifest file '{manifestPath}' could not be deserialized.");
|
||||||
|
// @formatter:on
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Write(string manifestPath, StaticAssetsManifest manifest)
|
||||||
|
{
|
||||||
|
File.Delete(manifestPath);
|
||||||
|
using var stream = File.OpenWrite(manifestPath);
|
||||||
|
JsonSerializer.Serialize(stream, manifest, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StaticAssetsManifest Fixup(string manifestPath, StaticAssetsManifest manifest)
|
||||||
|
{
|
||||||
|
// Get a list of constrained routes
|
||||||
|
var brotliRoutes = manifest.Endpoints
|
||||||
|
.Where(p => p.Selectors is [{ Name: "Content-Encoding", Value: "br" }])
|
||||||
|
.ToDictionary(p => p.Route,
|
||||||
|
p => p.ResponseHeaders
|
||||||
|
.FirstOrDefault(i => i.Name == "Content-Length"));
|
||||||
|
|
||||||
|
// Remove gzip-compressed versions
|
||||||
|
foreach (var endpoint in manifest.Endpoints.ToArray())
|
||||||
|
{
|
||||||
|
if (!endpoint.Selectors.Any(p => p is { Name: "Content-Encoding", Value: "gzip" }))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
manifest.Endpoints.Remove(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite uncompressed versions to reference brotli-compressed asset instead
|
||||||
|
foreach (var endpoint in manifest.Endpoints.ToArray())
|
||||||
|
{
|
||||||
|
if (endpoint.Selectors.Count > 0) continue;
|
||||||
|
if (!brotliRoutes.TryGetValue(endpoint.AssetPath, out var len)) continue;
|
||||||
|
if (len is null) throw new Exception($"Couldn't find content-length for route ${endpoint.Route}");
|
||||||
|
var origLen = endpoint.ResponseHeaders.First(p => p.Name == len.Name);
|
||||||
|
endpoint.Properties.Add(new StaticAssetProperty("Uncompressed-Length", origLen.Value));
|
||||||
|
endpoint.ResponseHeaders.Remove(origLen);
|
||||||
|
endpoint.ResponseHeaders.Add(len);
|
||||||
|
endpoint.AssetPath += ".br";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove explicit routes
|
||||||
|
manifest.Endpoints.RemoveAll(p => p.Route.EndsWith(".br"));
|
||||||
|
|
||||||
|
// Clean up endpoints
|
||||||
|
var path = Path.GetDirectoryName(manifestPath) ?? throw new Exception("Invalid path");
|
||||||
|
manifest.Endpoints.RemoveAll(p => !File.Exists(Path.Combine(path, "wwwroot", p.AssetPath)));
|
||||||
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class StaticAssetsManifest
|
||||||
|
{
|
||||||
|
public int Version { get; set; }
|
||||||
|
|
||||||
|
public string ManifestType { get; set; } = "";
|
||||||
|
|
||||||
|
// ReSharper disable once CollectionNeverUpdated.Local
|
||||||
|
public List<StaticAssetDescriptor> Endpoints { get; set; } = [];
|
||||||
|
|
||||||
|
public bool IsBuildManifest() => string.Equals(ManifestType, "Build", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The description of a static asset that was generated during the build process.
|
||||||
|
/// </summary>
|
||||||
|
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
|
||||||
|
public sealed class StaticAssetDescriptor
|
||||||
|
{
|
||||||
|
private string? _route;
|
||||||
|
private string? _assetFile;
|
||||||
|
private List<StaticAssetSelector> _selectors = [];
|
||||||
|
private List<StaticAssetProperty> _endpointProperties = [];
|
||||||
|
private List<StaticAssetResponseHeader> _responseHeaders = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The route that the asset is served from.
|
||||||
|
/// </summary>
|
||||||
|
public required string Route
|
||||||
|
{
|
||||||
|
get => _route ?? throw new InvalidOperationException("Route is required");
|
||||||
|
set => _route = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The path to the asset file from the wwwroot folder.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("AssetFile")]
|
||||||
|
public required string AssetPath
|
||||||
|
{
|
||||||
|
get => _assetFile ?? throw new InvalidOperationException("AssetPath is required");
|
||||||
|
set => _assetFile = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A list of selectors that are used to discriminate between two or more assets with the same route.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("Selectors")]
|
||||||
|
public List<StaticAssetSelector> Selectors
|
||||||
|
{
|
||||||
|
get => _selectors;
|
||||||
|
set => _selectors = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A list of properties that are associated with the endpoint.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("EndpointProperties")]
|
||||||
|
public List<StaticAssetProperty> Properties
|
||||||
|
{
|
||||||
|
get => _endpointProperties;
|
||||||
|
set => _endpointProperties = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A list of headers to apply to the response when this resource is served.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("ResponseHeaders")]
|
||||||
|
public List<StaticAssetResponseHeader> ResponseHeaders
|
||||||
|
{
|
||||||
|
get => _responseHeaders;
|
||||||
|
set => _responseHeaders = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetDebuggerDisplay()
|
||||||
|
{
|
||||||
|
return $"Route: {Route} Path: {AssetPath}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A static asset selector. Selectors are used to discriminate between two or more assets with the same route.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The name associated to the selector.</param>
|
||||||
|
/// <param name="value">The value associated to the selector and used to match against incoming requests.</param>
|
||||||
|
/// <param name="quality">The static server quality associated to this selector.</param>
|
||||||
|
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
|
||||||
|
public sealed class StaticAssetSelector(string name, string value, string quality)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The name associated to the selector.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; } = name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The value associated to the selector and used to match against incoming requests.
|
||||||
|
/// </summary>
|
||||||
|
public string Value { get; } = value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The static asset server quality associated to this selector. Used to break ties when a request matches multiple values
|
||||||
|
/// with the same degree of specificity.
|
||||||
|
/// </summary>
|
||||||
|
public string Quality { get; } = quality;
|
||||||
|
|
||||||
|
private string GetDebuggerDisplay() => $"Name: {Name} Value: {Value} Quality: {Quality}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A property associated with a static asset.
|
||||||
|
/// </summary>
|
||||||
|
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
|
||||||
|
public sealed class StaticAssetProperty(string name, string value)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the property.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; } = name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The value of the property.
|
||||||
|
/// </summary>
|
||||||
|
public string Value { get; } = value;
|
||||||
|
|
||||||
|
private string GetDebuggerDisplay() => $"Name: {Name} Value:{Value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A response header to apply to the response when a static asset is served.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="name">The name of the header.</param>
|
||||||
|
/// <param name="value">The value of the header.</param>
|
||||||
|
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
|
||||||
|
public sealed class StaticAssetResponseHeader(string name, string value)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The name of the header.
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; } = name;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The value of the header.
|
||||||
|
/// </summary>
|
||||||
|
public string Value { get; } = value;
|
||||||
|
|
||||||
|
private string GetDebuggerDisplay() => $"Name: {Name} Value: {Value}";
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Frontend", "Icesh
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Shared", "Iceshrimp.Shared\Iceshrimp.Shared.csproj", "{25E8E423-D2F7-437B-8E9B-5277BA5CE3CD}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Shared", "Iceshrimp.Shared\Iceshrimp.Shared.csproj", "{25E8E423-D2F7-437B-8E9B-5277BA5CE3CD}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{2000A25C-AF38-47BC-9432-D1278C12010B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Iceshrimp.Build", "Iceshrimp.Build\Iceshrimp.Build.csproj", "{CDF0DAD4-50D8-4885-829B-116CA0208239}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
@ -36,5 +40,12 @@ Global
|
||||||
{25E8E423-D2F7-437B-8E9B-5277BA5CE3CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{25E8E423-D2F7-437B-8E9B-5277BA5CE3CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{25E8E423-D2F7-437B-8E9B-5277BA5CE3CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{25E8E423-D2F7-437B-8E9B-5277BA5CE3CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{25E8E423-D2F7-437B-8E9B-5277BA5CE3CD}.Release|Any CPU.Build.0 = Release|Any CPU
|
{25E8E423-D2F7-437B-8E9B-5277BA5CE3CD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CDF0DAD4-50D8-4885-829B-116CA0208239}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CDF0DAD4-50D8-4885-829B-116CA0208239}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CDF0DAD4-50D8-4885-829B-116CA0208239}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CDF0DAD4-50D8-4885-829B-116CA0208239}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{CDF0DAD4-50D8-4885-829B-116CA0208239} = {2000A25C-AF38-47BC-9432-D1278C12010B}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|
Loading…
Add table
Reference in a new issue