418 lines
17 KiB
C#
418 lines
17 KiB
C#
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)
|
|
};
|
|
}
|
|
}
|
|
}
|