a fork of iceshrimp.net but a tweaked frontend to my personal liking. waow
fediverse
social-media
social
iceshrimp
fedi
1using System.Globalization;
2using System.Text.Json;
3using System.Text.Json.Serialization;
4using Microsoft.Build.Framework;
5
6// ReSharper disable UnusedMember.Local
7// ReSharper disable CheckNamespace
8// ReSharper disable ClassNeverInstantiated.Local
9// ReSharper disable ConvertToAutoProperty
10// ReSharper disable PropertyCanBeMadeInitOnly.Local
11
12namespace Iceshrimp.Build.Tasks;
13
14public class RewriteStaticAssetManifest : Microsoft.Build.Utilities.Task
15{
16 [System.ComponentModel.DataAnnotations.Required]
17 public required ITaskItem Manifest { get; set; }
18
19 public override bool Execute()
20 {
21 var parsed = Parse(Manifest.ItemSpec);
22 var @fixed = Fixup(Manifest.ItemSpec, parsed);
23 Write(Manifest.ItemSpec, @fixed);
24 return true;
25 }
26
27 private static StaticAssetsManifest Parse(string manifestPath)
28 {
29 using var stream = File.OpenRead(manifestPath);
30 using var reader = new StreamReader(stream);
31 var content = reader.ReadToEnd();
32
33 // @formatter:off
34 var result = JsonSerializer.Deserialize<StaticAssetsManifest>(content) ??
35 throw new InvalidOperationException($"The static resources manifest file '{manifestPath}' could not be deserialized.");
36 // @formatter:on
37
38 return result;
39 }
40
41 private static void Write(string manifestPath, StaticAssetsManifest manifest)
42 {
43 File.Delete(manifestPath);
44 using var stream = File.OpenWrite(manifestPath);
45 JsonSerializer.Serialize(stream, manifest, new JsonSerializerOptions { WriteIndented = true });
46 }
47
48 private static StaticAssetsManifest Fixup(string manifestPath, StaticAssetsManifest manifest)
49 {
50 // Get a list of constrained routes
51 var brotliRoutes = manifest.Endpoints
52 .Where(p => p.Selectors is [{ Name: "Content-Encoding", Value: "br" }])
53 .DistinctBy(p => p.AssetPath)
54 .ToDictionary(p => p.AssetPath,
55 p => p.ResponseHeaders
56 .FirstOrDefault(i => i.Name == "Content-Length"));
57
58 var arr = manifest.Endpoints.ToArray();
59
60 // Rewrite uncompressed versions to reference brotli-compressed asset instead
61 foreach (var endpoint in arr)
62 {
63 if (endpoint.Selectors.Count > 0) continue;
64 if (!brotliRoutes.TryGetValue(endpoint.AssetPath + ".br", out var len))
65 continue;
66
67 if (len is null) throw new Exception($"Couldn't find content-length for route ${endpoint.Route}");
68 var origLen = endpoint.ResponseHeaders.First(p => p.Name == len.Name);
69 endpoint.Properties.Add(new StaticAssetProperty("Uncompressed-Length", origLen.Value));
70 endpoint.ResponseHeaders.Remove(origLen);
71 endpoint.ResponseHeaders.Add(len);
72 endpoint.AssetPath += ".br";
73 }
74
75 // Fixup assets without a corresponding compressed selector entry
76 foreach (var endpoint in arr)
77 {
78 if (endpoint.Route == endpoint.AssetPath) continue;
79 if (endpoint.Selectors is not []) continue;
80 if (!endpoint.AssetPath.EndsWith(".br")) continue;
81 if (endpoint.Route.EndsWith(".br")) continue;
82 if (endpoint.ResponseHeaders.FirstOrDefault(p => p.Name == "Content-Length") is not { } lenHdr)
83 continue;
84 if (!long.TryParse(lenHdr.Value, out var len))
85 continue;
86 if (arr.Any(p => p.Route == endpoint.Route && p.AssetPath == endpoint.AssetPath && endpoint.Selectors is not []))
87 continue;
88
89 var quality = Math.Round(1.0 / (len + 1), 12).ToString("F12", CultureInfo.InvariantCulture);
90
91 manifest.Endpoints.Add(new StaticAssetDescriptor
92 {
93 Route = endpoint.Route,
94 AssetPath = endpoint.AssetPath,
95 Properties = endpoint.Properties,
96 ResponseHeaders = [..endpoint.ResponseHeaders, new StaticAssetResponseHeader("Content-Encoding", "br")],
97 Selectors = [new StaticAssetSelector("Content-Encoding", "br", quality)]
98 });
99 }
100
101 // Remove explicit routes
102 manifest.Endpoints.RemoveAll(p => p.Route.EndsWith(".br"));
103
104 // Clean up endpoints
105 var path = Path.GetDirectoryName(manifestPath) ?? throw new Exception("Invalid path");
106 manifest.Endpoints.RemoveAll(p => !File.Exists(Path.Combine(path, "wwwroot", p.AssetPath)));
107 return manifest;
108 }
109
110 private class StaticAssetsManifest
111 {
112 public int Version { get; set; }
113
114 public string ManifestType { get; set; } = "";
115
116 // ReSharper disable once CollectionNeverUpdated.Local
117 public List<StaticAssetDescriptor> Endpoints { get; set; } = [];
118 }
119
120 private sealed class StaticAssetDescriptor
121 {
122 private List<StaticAssetSelector> _selectors = [];
123 private List<StaticAssetProperty> _endpointProperties = [];
124 private List<StaticAssetResponseHeader> _responseHeaders = [];
125
126 public required string Route
127 {
128 get => field ?? throw new InvalidOperationException("Route is required");
129 set;
130 }
131
132 [JsonPropertyName("AssetFile")]
133 public required string AssetPath
134 {
135 get => field ?? throw new InvalidOperationException("AssetPath is required");
136 set;
137 }
138
139 [JsonPropertyName("Selectors")]
140 public List<StaticAssetSelector> Selectors
141 {
142 get => _selectors;
143 set => _selectors = value;
144 }
145
146 [JsonPropertyName("EndpointProperties")]
147 public List<StaticAssetProperty> Properties
148 {
149 get => _endpointProperties;
150 set => _endpointProperties = value;
151 }
152
153 [JsonPropertyName("ResponseHeaders")]
154 public List<StaticAssetResponseHeader> ResponseHeaders
155 {
156 get => _responseHeaders;
157 set => _responseHeaders = value;
158 }
159 }
160
161 private sealed class StaticAssetSelector(string name, string value, string quality)
162 {
163 public string Name { get; } = name;
164 public string Value { get; } = value;
165 public string Quality { get; } = quality;
166 }
167
168 private sealed class StaticAssetProperty(string name, string value)
169 {
170 public string Name { get; } = name;
171 public string Value { get; } = value;
172 }
173
174 private sealed class StaticAssetResponseHeader(string name, string value)
175 {
176 public string Name { get; } = name;
177 public string Value { get; } = value;
178 }
179}