using Microsoft.AspNetCore.Mvc; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Formats; namespace RadicadeServer.Images { [Route("images")] public class ImageAPI : Controller { private const string SignatureData = "IWwe/pZ5vWWqNSkSM/54isgDxlZkdrP0sUrppKCbNktO2yCOTjq746xWiiLsueGuVcAGQqkjeRTimxolHckS/YXSYkEJxtiCXbLlsRia2DyAqtWVkGWsfczzFhp/56U66FVzolTspPCvjScOVlGO7dDIK7sJ+ndcRauWjsQsC6g3e7rUc6uwY099a6gy7sw6xr5BFZQSz8wg+fqyHYD/Sc4nQQVOTFZNNASqbJYhpNhEMXRnafCMuLl8a3mkGwvy3t4q2D/7SM48xrGZjEV47qNx1A91KCe28XVToFh4BzwEUU8nZ0d+KwV79MGarLo1cY8igc8FcoThKcovI4ClOg=="; public const string PatchedKeyId = "KEY:RSA:p1.radicade.internal"; public const string StockKeyId = "KEY:RSA:p1.rec.net"; public static readonly string SignatureString = BuildSignature(PatchedKeyId); public static readonly string StockSignatureString = BuildSignature(StockKeyId); public const string KeyIdQueryParameter = "rcsig"; public static readonly string BareRouteKeyId = IsWellFormedKeyId(Environment.GetEnvironmentVariable("RADICADE_IMAGE_BARE_KEYID") ?? string.Empty) ? Environment.GetEnvironmentVariable("RADICADE_IMAGE_BARE_KEYID") ?? StockKeyId : StockKeyId; private static string BuildSignature(string keyId) => $"key-id={keyId}; data={SignatureData}"; [HttpGet("img/{imageName}")] public async Task GetImageFile(string imageName, [FromQuery] int? width, [FromQuery] int? cropSquare, [FromQuery] string sig, [FromQuery(Name = KeyIdQueryParameter)] string clientKeyId) { return await ProcessImage(imageName, width, cropSquare, false, SignatureFor(clientKeyId, "img", PatchedKeyId)); } [HttpGet("{imageName}")] public async Task GetImageFileAlt(string imageName, [FromQuery] int? width, [FromQuery] int? cropSquare, [FromQuery] string sig, [FromQuery(Name = KeyIdQueryParameter)] string clientKeyId) { return await ProcessImage(imageName, width, cropSquare, false, SignatureFor(clientKeyId, "bare", BareRouteKeyId)); } [HttpGet("default")] public async Task GetDefaultImage([FromQuery] int? width, [FromQuery] int? cropSquare, [FromQuery] string sig, [FromQuery(Name = KeyIdQueryParameter)] string clientKeyId) { return await ProcessImage(DBConfig.DefaultRoomImage, width, cropSquare, false, SignatureFor(clientKeyId, "default", BareRouteKeyId)); } public static string SignatureFor(string clientKeyId, string route, string routeDefaultKeyId) { var keyId = IsWellFormedKeyId(clientKeyId) ? clientKeyId : routeDefaultKeyId; return BuildSignature(keyId); } public static bool IsWellFormedKeyId(string keyId) { const string prefix = "KEY:RSA:p1."; if (string.IsNullOrEmpty(keyId) || keyId.Length > 96 || !keyId.StartsWith(prefix, StringComparison.Ordinal)) return false; var tail = keyId.Substring(prefix.Length); if (tail.Length == 0 || tail.Length > 64) return false; foreach (var c in tail) { if (!char.IsLetterOrDigit(c) && c != '.' && c != '-' && c != '_') return false; } return true; } public static string[] AssetRoots() => new[] { DBConfig.CDNImages, DBConfig.CDNCustom, DBConfig.CDNStorage }; private static readonly string[] ExtensionVariants = { ".png", ".jpg", ".jpeg" }; public static string? ResolveAsset(string imageName) => ResolveAsset(imageName, AssetRoots()); public static string? ResolveAsset(string imageName, IEnumerable roots) { var name = Path.GetFileName(imageName); if (string.IsNullOrWhiteSpace(name) || name == "." || name == "..") return null; foreach (var candidate in CandidateNames(name)) { foreach (var root in roots) { var path = Path.Join(root, candidate); if (WithinRoot(root, path) && System.IO.File.Exists(path)) { return path; } } } return null; } private static IEnumerable CandidateNames(string name) { yield return name; var existing = Path.GetExtension(name); foreach (var variant in ExtensionVariants) { if (existing.Length == 0) { yield return name + variant; } else if (!string.Equals(existing, variant, StringComparison.OrdinalIgnoreCase)) { yield return string.Concat(name.AsSpan(0, name.Length - existing.Length), variant); } } } private static bool WithinRoot(string root, string path) { try { var fullRoot = Path.GetFullPath(root) + Path.DirectorySeparatorChar; return Path.GetFullPath(path).StartsWith(fullRoot, StringComparison.Ordinal); } catch (ArgumentException) { return false; } } private async Task ProcessImage(string imageName, int? width, int? cropSquare, bool isFallback = false, string? signature = null) { Response.Headers["Content-Signature"] = signature ?? SignatureString; var fullPath = ResolveAsset(imageName); if (fullPath == null) { if (!isFallback) { var fallback = ResolveAsset(DBConfig.DefaultRoomImage); if (fallback != null) { return await ServeImageFile(fallback, width, cropSquare); } } return NotFound(); } return await ServeImageFile(fullPath, width, cropSquare); } private async Task ServeImageFile(string fullPath, int? width, int? cropSquare) { if (!width.HasValue) { return PhysicalFile(fullPath, "image/jpeg"); } Image image; try { image = await Image.LoadAsync(fullPath); } catch (Exception) { return PhysicalFile(fullPath, "image/jpeg"); } using (image) { if (cropSquare == 1) { int size = Math.Min(image.Width, image.Height); var cropRect = new Rectangle((image.Width - size) / 2, (image.Height - size) / 2, size, size); image.Mutate(x => { x.Crop(cropRect); x.Resize(width.Value, width.Value); }); } else { image.Mutate(x => x.Resize(width.Value, 0)); } IImageFormat format = await Image.DetectFormatAsync(fullPath); var ms = new MemoryStream(); await image.SaveAsync(ms, format); ms.Position = 0; return File(ms, format.DefaultMimeType); } } } }