using MailSharp.MailClient.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.StaticFiles; namespace MailSharp.MailClient.Controllers.Api; // Serves cached external images referenced by allowed-sender messages, at // /cache/images/{emailAddress}/{messageId}/{fileName} (fileName is "01.jpg" etc., see // ImageSanitizer) so it's obvious on disk which images belong to which message. On the first // request for a given file it doesn't exist yet: the handler looks up the original URL, // downloads it now, saves it to disk, and serves it - every later request for that same file is // then a plain disk read. Anonymous on purpose: the lives inside a sandboxed srcdoc iframe // (opaque origin), so the auth cookie would not reliably be sent for this subresource anyway - // and a cached image is just already-public picture content, not account data, so serving it // without a session is a low-risk trade-off. [AllowAnonymous] [ApiController] public class ImageCacheController(IMailCacheStore cacheStore, IHttpClientFactory httpClientFactory) : ControllerBase { private static readonly FileExtensionContentTypeProvider ContentTypeProvider = new(); [HttpGet("/cache/images/{emailAddress}/{messageId}/{fileName}")] public async Task GetImage(string emailAddress, string messageId, string fileName) { var existingPath = cacheStore.GetCachedImagePath(emailAddress, messageId, fileName); if (existingPath != null) { return PhysicalFile(existingPath, ResolveContentType(existingPath)); } var url = cacheStore.GetImageSourceUrl(emailAddress, messageId, fileName); if (url == null) return NotFound(); try { var client = httpClientFactory.CreateClient("ImageDownload"); using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); if (!response.IsSuccessStatusCode) return NotFound(); var contentType = response.Content.Headers.ContentType?.MediaType ?? ""; if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) return NotFound(); var bytes = await response.Content.ReadAsByteArrayAsync(); if (bytes.Length == 0 || bytes.Length > 10_000_000) return NotFound(); // The source URL had no extension (e.g. "/picture" with no ".jpg"): fall back to // guessing one from the response's actual Content-Type so the cached file isn't left // extensionless on disk. var finalFileName = EnsureExtension(fileName, contentType); var savedPath = cacheStore.SaveCachedImageFile(emailAddress, messageId, finalFileName, bytes); return PhysicalFile(savedPath, contentType); } catch { return NotFound(); } } private static string EnsureExtension(string fileName, string contentType) { if (fileName.Contains('.')) return fileName; var extension = contentType.ToLowerInvariant() switch { "image/jpeg" or "image/jpg" => ".jpg", "image/png" => ".png", "image/gif" => ".gif", "image/webp" => ".webp", "image/bmp" => ".bmp", "image/svg+xml" => ".svg", "image/tiff" => ".tiff", "image/x-icon" or "image/vnd.microsoft.icon" => ".ico", "image/avif" => ".avif", "image/heic" => ".heic", _ => "" }; return fileName + extension; } private static string ResolveContentType(string path) => ContentTypeProvider.TryGetContentType(path, out var contentType) ? contentType : "application/octet-stream"; }