:3
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace Mocha2023.Classes
|
||||
{
|
||||
|
||||
public static class LoadingScreenImageService
|
||||
{
|
||||
private const long MaxDownloadBytes = 8 * 1024 * 1024;
|
||||
private const int MaxSourceDimension = 4096;
|
||||
private const int MaxOutputDimension = 2048;
|
||||
private const int MaxRedirects = 3;
|
||||
private static readonly TimeSpan CacheLifetime = TimeSpan.FromHours(24);
|
||||
private static readonly ConcurrentDictionary<string, Uri> RegisteredUrls =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> AliasLocks =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly SemaphoreSlim DownloadSlots = new(3, 3);
|
||||
private static readonly HttpClient Client = CreateClient();
|
||||
|
||||
public static string RewriteConfiguration(string json)
|
||||
{
|
||||
JsonNode? root = JsonNode.Parse(
|
||||
json,
|
||||
documentOptions: new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
if (root == null)
|
||||
throw new JsonException("Loading-screen configuration was empty.");
|
||||
|
||||
RewriteNode(root);
|
||||
return root.ToJsonString(new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
WriteIndented = false
|
||||
});
|
||||
}
|
||||
|
||||
public static bool IsRemoteAlias(string? imageName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageName))
|
||||
return false;
|
||||
|
||||
string normalized = imageName.Replace('\\', '/').TrimStart('/');
|
||||
if (!normalized.StartsWith("RemoteLoadingScreens/", StringComparison.OrdinalIgnoreCase) ||
|
||||
!normalized.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
string hash = Path.GetFileNameWithoutExtension(normalized);
|
||||
return hash.Length == 64 && hash.All(Uri.IsHexDigit);
|
||||
}
|
||||
|
||||
public static async Task<string?> GetLocalImageAsync(
|
||||
string imageName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string alias = imageName.Replace('\\', '/').TrimStart('/');
|
||||
if (!IsRemoteAlias(alias) || !RegisteredUrls.TryGetValue(alias, out Uri? source))
|
||||
return null;
|
||||
|
||||
string cacheDirectory = Path.Combine(
|
||||
Program.dataDir,
|
||||
"Images",
|
||||
"RemoteLoadingScreens");
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
string cachePath = Path.Combine(cacheDirectory, Path.GetFileName(alias));
|
||||
|
||||
if (IsFresh(cachePath))
|
||||
return cachePath;
|
||||
|
||||
SemaphoreSlim aliasLock = AliasLocks.GetOrAdd(alias, _ => new SemaphoreSlim(1, 1));
|
||||
await aliasLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (IsFresh(cachePath))
|
||||
return cachePath;
|
||||
|
||||
await DownloadSlots.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
byte[] bytes = await DownloadAsync(source, cancellationToken);
|
||||
await NormalizeAndSaveAsync(bytes, cachePath, cancellationToken);
|
||||
return cachePath;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Console.WriteLine($"[loading image] {source.Host}: download timed out");
|
||||
return System.IO.File.Exists(cachePath) ? cachePath : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Console.WriteLine($"[loading image] {source.Host}: {ex.Message}");
|
||||
return System.IO.File.Exists(cachePath) ? cachePath : null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DownloadSlots.Release();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
aliasLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RewriteNode(JsonNode node)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
string? explicitUrlKey = FindKey(obj, "ImageUrl");
|
||||
JsonNode? explicitUrlNode = explicitUrlKey == null ? null : obj[explicitUrlKey];
|
||||
string? explicitImageUrl = explicitUrlNode is JsonValue explicitUrlValue &&
|
||||
explicitUrlValue.TryGetValue(out string? parsedImageUrl)
|
||||
? parsedImageUrl
|
||||
: null;
|
||||
if (TryRegister(explicitImageUrl, out string? explicitAlias))
|
||||
{
|
||||
|
||||
string targetKey = FindKey(obj, "ThumbnailBlobName") ??
|
||||
FindKey(obj, "ThumbnailImageName") ??
|
||||
FindKey(obj, "ImageName") ??
|
||||
"ImageName";
|
||||
obj[targetKey] = explicitAlias;
|
||||
}
|
||||
|
||||
foreach (string key in obj.Select(pair => pair.Key).ToArray())
|
||||
{
|
||||
JsonNode? value = obj[key];
|
||||
if (IsImageNameField(key) &&
|
||||
value is JsonValue jsonValue &&
|
||||
jsonValue.TryGetValue(out string? imageName) &&
|
||||
TryRegister(imageName, out string? alias))
|
||||
{
|
||||
obj[key] = alias;
|
||||
}
|
||||
else if (value != null)
|
||||
{
|
||||
RewriteNode(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
{
|
||||
foreach (JsonNode? value in array)
|
||||
{
|
||||
if (value != null)
|
||||
RewriteNode(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindKey(JsonObject obj, string expected) =>
|
||||
obj.Select(pair => pair.Key).FirstOrDefault(key =>
|
||||
key.Equals(expected, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsImageNameField(string key) =>
|
||||
key.Equals("ImageName", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Equals("ThumbnailBlobName", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Equals("ThumbnailImageName", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool TryRegister(string? value, out string? alias)
|
||||
{
|
||||
alias = null;
|
||||
if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out Uri? uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) ||
|
||||
!string.IsNullOrEmpty(uri.UserInfo))
|
||||
return false;
|
||||
|
||||
if (TryResolveLocalImageUrl(uri, out alias))
|
||||
return true;
|
||||
|
||||
string canonical = uri.GetComponents(UriComponents.HttpRequestUrl, UriFormat.UriEscaped);
|
||||
string hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)))
|
||||
.ToLowerInvariant();
|
||||
alias = $"RemoteLoadingScreens/{hash}.png";
|
||||
RegisteredUrls[alias] = uri;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryResolveLocalImageUrl(Uri uri, out string? imageName)
|
||||
{
|
||||
imageName = null;
|
||||
if (!Uri.TryCreate(ServerConfig.BaseURL, UriKind.Absolute, out Uri? configuredBase))
|
||||
configuredBase = null;
|
||||
|
||||
bool isOwnHost = uri.IsLoopback || configuredBase != null &&
|
||||
uri.Scheme.Equals(configuredBase.Scheme, StringComparison.OrdinalIgnoreCase) &&
|
||||
uri.Host.Equals(configuredBase.Host, StringComparison.OrdinalIgnoreCase) &&
|
||||
uri.Port == configuredBase.Port;
|
||||
string? prefix = new[] { "/imageserver-v2/", "/imageserver/" }
|
||||
.FirstOrDefault(candidate => uri.AbsolutePath.StartsWith(
|
||||
candidate,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (!isOwnHost || prefix == null)
|
||||
return false;
|
||||
|
||||
string relative;
|
||||
try
|
||||
{
|
||||
relative = Uri.UnescapeDataString(uri.AbsolutePath[prefix.Length..])
|
||||
.Replace('\\', '/')
|
||||
.TrimStart('/');
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relative) || relative.Contains(':'))
|
||||
return false;
|
||||
|
||||
string root;
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
root = Path.GetFullPath(Path.Combine(Program.dataDir, "Images"));
|
||||
fullPath = Path.GetFullPath(Path.Combine(
|
||||
root,
|
||||
relative.Replace('/', Path.DirectorySeparatorChar)));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!fullPath.StartsWith(
|
||||
root + Path.DirectorySeparatorChar,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
imageName = relative;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFresh(string path) =>
|
||||
System.IO.File.Exists(path) &&
|
||||
DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(path) < CacheLifetime;
|
||||
|
||||
private static async Task<byte[]> DownloadAsync(
|
||||
Uri source,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Uri current = source;
|
||||
for (int redirect = 0; redirect <= MaxRedirects; redirect++)
|
||||
{
|
||||
await EnsurePublicHostAsync(current, cancellationToken);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, current);
|
||||
request.Headers.UserAgent.ParseAdd("MochaLoadingScreen/1.0");
|
||||
using HttpResponseMessage response = await Client.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if ((int)response.StatusCode is >= 300 and < 400)
|
||||
{
|
||||
if (redirect == MaxRedirects || response.Headers.Location == null)
|
||||
throw new InvalidDataException("Too many image redirects.");
|
||||
Uri next = response.Headers.Location.IsAbsoluteUri
|
||||
? response.Headers.Location
|
||||
: new Uri(current, response.Headers.Location);
|
||||
if ((next.Scheme != Uri.UriSchemeHttps && next.Scheme != Uri.UriSchemeHttp) ||
|
||||
(current.Scheme == Uri.UriSchemeHttps && next.Scheme == Uri.UriSchemeHttp))
|
||||
throw new InvalidDataException("Unsafe image redirect.");
|
||||
current = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
if (response.Content.Headers.ContentLength > MaxDownloadBytes)
|
||||
throw new InvalidDataException("Remote image exceeds 8 MB.");
|
||||
|
||||
string? mediaType = response.Content.Headers.ContentType?.MediaType;
|
||||
if (mediaType != null && !mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidDataException("Remote URL did not return an image.");
|
||||
|
||||
await using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var output = new MemoryStream();
|
||||
byte[] buffer = new byte[64 * 1024];
|
||||
long total = 0;
|
||||
while (true)
|
||||
{
|
||||
int read = await stream.ReadAsync(buffer, cancellationToken);
|
||||
if (read == 0)
|
||||
break;
|
||||
total += read;
|
||||
if (total > MaxDownloadBytes)
|
||||
throw new InvalidDataException("Remote image exceeds 8 MB.");
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
throw new InvalidDataException("Unable to download remote image.");
|
||||
}
|
||||
|
||||
private static async Task NormalizeAndSaveAsync(
|
||||
byte[] bytes,
|
||||
string cachePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ImageInfo? info = Image.Identify(bytes);
|
||||
if (info == null || info.Width <= 0 || info.Height <= 0 ||
|
||||
info.Width > MaxSourceDimension || info.Height > MaxSourceDimension ||
|
||||
(long)info.Width * info.Height > 16_777_216)
|
||||
throw new InvalidDataException("Remote image dimensions are too large.");
|
||||
|
||||
using Image image = Image.Load(bytes);
|
||||
while (image.Frames.Count > 1)
|
||||
image.Frames.RemoveFrame(image.Frames.Count - 1);
|
||||
if (image.Width > MaxOutputDimension || image.Height > MaxOutputDimension)
|
||||
{
|
||||
image.Mutate(context => context.Resize(new ResizeOptions
|
||||
{
|
||||
Mode = ResizeMode.Max,
|
||||
Size = new Size(MaxOutputDimension, MaxOutputDimension)
|
||||
}));
|
||||
}
|
||||
|
||||
string tempPath = cachePath + ".tmp-" + Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
await image.SaveAsync(tempPath, new PngEncoder(), cancellationToken);
|
||||
System.IO.File.Move(tempPath, cachePath, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (System.IO.File.Exists(tempPath))
|
||||
System.IO.File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EnsurePublicHostAsync(
|
||||
Uri uri,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (uri.IsLoopback || string.IsNullOrWhiteSpace(uri.Host))
|
||||
throw new InvalidDataException("Local image URLs are not allowed.");
|
||||
|
||||
IPAddress[] addresses = await Dns.GetHostAddressesAsync(uri.DnsSafeHost, cancellationToken);
|
||||
if (addresses.Length == 0 || addresses.Any(address => !IsPublicAddress(address)))
|
||||
throw new InvalidDataException("Private-network image URLs are not allowed.");
|
||||
}
|
||||
|
||||
private static bool IsPublicAddress(IPAddress address)
|
||||
{
|
||||
if (address.IsIPv4MappedToIPv6)
|
||||
address = address.MapToIPv4();
|
||||
if (IPAddress.IsLoopback(address) || address.Equals(IPAddress.Any) ||
|
||||
address.Equals(IPAddress.IPv6Any) || address.Equals(IPAddress.None) ||
|
||||
address.Equals(IPAddress.IPv6None))
|
||||
return false;
|
||||
|
||||
byte[] bytes = address.GetAddressBytes();
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
return !(bytes[0] is 0 or 10 or 127 ||
|
||||
(bytes[0] == 100 && bytes[1] is >= 64 and <= 127) ||
|
||||
(bytes[0] == 169 && bytes[1] == 254) ||
|
||||
(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) ||
|
||||
(bytes[0] == 192 && bytes[1] == 168) ||
|
||||
(bytes[0] == 198 && bytes[1] is 18 or 19) ||
|
||||
bytes[0] >= 224);
|
||||
}
|
||||
|
||||
return !(address.IsIPv6LinkLocal || address.IsIPv6Multicast ||
|
||||
address.IsIPv6SiteLocal || bytes.All(value => value == 0) ||
|
||||
(bytes[0] & 0xFE) == 0xFC);
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient()
|
||||
{
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false,
|
||||
UseProxy = false,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(3),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
ConnectCallback = async (context, cancellationToken) =>
|
||||
{
|
||||
IPAddress[] addresses = await Dns.GetHostAddressesAsync(
|
||||
context.DnsEndPoint.Host,
|
||||
cancellationToken);
|
||||
if (addresses.Length == 0 || addresses.Any(address => !IsPublicAddress(address)))
|
||||
throw new InvalidDataException("Private-network image URLs are not allowed.");
|
||||
|
||||
IPAddress address = addresses[Random.Shared.Next(addresses.Length)];
|
||||
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(
|
||||
new IPEndPoint(address, context.DnsEndPoint.Port),
|
||||
cancellationToken);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
};
|
||||
return new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(8)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user