From 42a49166fcf2168844f3b63f90f0bf7730490d50 Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 07:16:58 +0200 Subject: [PATCH 1/2] feat(ui): add ImageCacheService, ImageLoader control, and Avalonia value converters --- .../Constants/ImageCacheConstants.cs | 37 ++ GenHub/GenHub.Core/Constants/UiConstants.cs | 45 ++ GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs | 145 +++++ .../GenHub.Core/Models/Enums/ContentState.cs | 23 + .../Converters/StripHtmlConverterTests.cs | 82 +++ .../Helpers/HtmlTextHelperTests.cs | 147 +++++ .../ApplicationDataPathConventionTests.cs | 3 + .../Converters/StringToImageConverterTests.cs | 6 +- .../Services/ImageCacheServiceTests.cs | 97 +++ .../Infrastructure/Controls/ImageLoader.cs | 111 ++++ .../Converters/BoolToBackgroundConverter.cs | 37 ++ .../Converters/BoolToBorderConverter.cs | 37 ++ .../Converters/ComparisonConverters.cs | 5 + .../ContentStateToBrushConverter.cs | 41 ++ .../ContentStateToPathDataConverter.cs | 38 ++ .../Converters/ContentStateToTextConverter.cs | 32 + .../Converters/GameTypeInitialConverter.cs | 45 ++ .../Converters/IndentToMarginConverter.cs | 40 ++ .../Converters/NotEqualToConverter.cs | 38 ++ .../Converters/StringToImageConverter.cs | 20 +- .../Converters/StripHtmlConverter.cs | 39 ++ .../Services/ImageCacheService.cs | 613 ++++++++++++++++++ scripts/build-check.ps1 | 249 +++++++ 23 files changed, 1920 insertions(+), 10 deletions(-) create mode 100644 GenHub/GenHub.Core/Constants/ImageCacheConstants.cs create mode 100644 GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs create mode 100644 GenHub/GenHub.Core/Models/Enums/ContentState.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs create mode 100644 GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs create mode 100644 GenHub/GenHub/Infrastructure/Controls/ImageLoader.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/BoolToBackgroundConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/BoolToBorderConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/ContentStateToBrushConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/ContentStateToPathDataConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/ContentStateToTextConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/GameTypeInitialConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/IndentToMarginConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/NotEqualToConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Converters/StripHtmlConverter.cs create mode 100644 GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs create mode 100644 scripts/build-check.ps1 diff --git a/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs b/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs new file mode 100644 index 000000000..a10b32379 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs @@ -0,0 +1,37 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for image downloading, validation, and caching. +/// +public static class ImageCacheConstants +{ + /// + /// Maximum allowed image download payload in bytes (15 MB). + /// + public const long MaxImageDownloadSizeBytes = 15L * 1024 * 1024; + + /// + /// Maximum number of bitmap entries stored in the memory LRU cache. + /// + public const int MaxMemoryCacheEntries = 200; + + /// + /// Maximum disk cache size in bytes (250 MB). + /// + public const long MaxDiskCacheSizeBytes = 250L * 1024 * 1024; + + /// + /// Time-to-live for disk-cached images in days. + /// + public const int DiskCacheTtlDays = 30; + + /// + /// Default HTTP timeout in seconds for downloading images. + /// + public const int DefaultTimeoutSeconds = 30; + + /// + /// Maximum allowed HTTP redirects when downloading images. + /// + public const int MaxRedirects = 5; +} diff --git a/GenHub/GenHub.Core/Constants/UiConstants.cs b/GenHub/GenHub.Core/Constants/UiConstants.cs index e3dc98279..bd61a5893 100644 --- a/GenHub/GenHub.Core/Constants/UiConstants.cs +++ b/GenHub/GenHub.Core/Constants/UiConstants.cs @@ -25,6 +25,21 @@ public static class UiConstants /// public const double DefaultProfileSettingsHeight = 700; + /// + /// Default width for the profile settings sidebar in pixels. + /// + public const double DefaultProfileSettingsSidebarWidth = 190; + + /// + /// Minimum width for the profile settings sidebar (shows icons only) in pixels. + /// + public const double MinProfileSettingsSidebarWidth = 68; + + /// + /// Maximum width for the profile settings sidebar in pixels. + /// + public const double MaxProfileSettingsSidebarWidth = 300; + // Status colors /// @@ -37,6 +52,36 @@ public static class UiConstants /// public const string StatusErrorColor = "#F44336"; + /// + /// color used for downloaded status indicator. + /// + public const string StatusDownloadedColor = "#4CAF50"; + + /// + /// color used for not downloaded status indicator. + /// + public const string StatusNotDownloadedColor = "#B388FF"; + + /// + /// color used for update available status indicator. + /// + public const string StatusUpdateAvailableColor = "#FFB74D"; + + /// + /// svg path data for transparent checkmark icon. + /// + public const string TransparentCheckmarkIconPath = "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"; + + /// + /// svg path data for detailed download arrow icon into tray. + /// + public const string DownloadArrowIconPath = "M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z"; + + /// + /// svg path data for update sync icon. + /// + public const string UpdateSyncIconPath = "M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"; + /// /// Default theme color for Generals content. /// diff --git a/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs new file mode 100644 index 000000000..b19f11d94 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs @@ -0,0 +1,145 @@ +using System; +using System.Net; +using System.Text.RegularExpressions; + +namespace GenHub.Core.Helpers; + +/// +/// Provides high-performance utilities for stripping HTML tags, decoding HTML entities, +/// and normalizing text descriptions for display across the application. +/// +public static partial class HtmlTextHelper +{ + /// + /// Converts an HTML snippet or formatted description into clean, normalized plain text: + /// - Replaces <br> and block element closures (</p>, </div>, etc.) with line breaks. + /// - Strips all remaining HTML tags. + /// - Decodes HTML entities (e.g., &amp;, &quot;, &gt;, &nbsp;). + /// - Normalizes whitespace and excessive blank lines. + /// - Uses the platform newline format. + /// + /// The raw HTML or formatted text string to normalize. + /// Normalized plain text, or empty string if input is null or whitespace. + public static string NormalizeHtml(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return string.Empty; + } + + // 0. Remove script and style elements along with their contents + var text = ScriptTagRegex().Replace(html, string.Empty); + text = StyleTagRegex().Replace(text, string.Empty); + + // 1. Convert
tags to newline + text = BrTagRegex().Replace(text, "\n"); + + // 2. Convert paragraph closing tags to double newline for paragraph separation + text = ParagraphCloseTagRegex().Replace(text, "\n\n"); + + // 3. Convert other block-level closing tags and
tags to newline + text = BlockCloseTagRegex().Replace(text, "\n"); + + // 4. Strip all remaining HTML/XML tags + text = HtmlTagRegex().Replace(text, string.Empty); + + // 5. Decode HTML entities ( , >, ", ', numeric entities, etc.) + text = WebUtility.HtmlDecode(text); + + // 6. Normalize non-breaking spaces and line endings + text = text.Replace('\u00A0', ' ') + .Replace("\r\n", "\n") + .Replace('\r', '\n'); + + // 7. Clean trailing whitespace on lines and collapse excess blank lines + text = TrailingWhitespaceBeforeNewlineRegex().Replace(text, "\n"); + text = ExcessBlankLinesRegex().Replace(text, "\n\n"); + + // 8. Trim and unify with environment newline + text = text.Trim(); + text = text.Replace("\n", Environment.NewLine); + + return text; + } + + /// + /// Converts an HTML snippet or multi-line text into a single-line summary without HTML tags, + /// collapsing all whitespace runs into a single space, and optionally truncating with an ellipsis. + /// + /// The input HTML or text string. + /// Optional maximum character length including ellipsis. + /// A single-line plain text summary. + public static string CleanToSingleLine(string? htmlOrText, int? maxLength = null) + { + if (string.IsNullOrWhiteSpace(htmlOrText)) + { + return string.Empty; + } + + // Strip HTML if tags exist, decode entities, and normalize + var text = NormalizeHtml(htmlOrText); + + // Collapse all newlines, tabs, and multiple spaces into a single space + text = MultiWhitespaceRegex().Replace(text, " ").Trim(); + + if (maxLength.HasValue && maxLength.Value > 0 && text.Length > maxLength.Value) + { + return TruncateWithEllipsis(text, maxLength.Value); + } + + return text; + } + + /// + /// Truncates a string to a specified maximum length and appends an ellipsis ("...") if truncated. + /// + /// The text to truncate. + /// The maximum allowed length (including the ellipsis). + /// The truncated text with an ellipsis if it exceeded maxLength, or the original text. + public static string TruncateWithEllipsis(string? text, int maxLength) + { + if (string.IsNullOrWhiteSpace(text) || maxLength <= 0) + { + return string.Empty; + } + + if (text.Length <= maxLength) + { + return text; + } + + if (maxLength <= 3) + { + return text[..maxLength]; + } + + return string.Concat(text.AsSpan(0, maxLength - 3), "..."); + } + + [GeneratedRegex(@"]*>[\s\S]*?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ScriptTagRegex(); + + [GeneratedRegex(@"]*>[\s\S]*?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex StyleTagRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BrTagRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ParagraphCloseTagRegex(); + + [GeneratedRegex(@"]*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BlockCloseTagRegex(); + + [GeneratedRegex(@"]*>", RegexOptions.CultureInvariant)] + private static partial Regex HtmlTagRegex(); + + [GeneratedRegex(@"[ \t]+\n", RegexOptions.CultureInvariant)] + private static partial Regex TrailingWhitespaceBeforeNewlineRegex(); + + [GeneratedRegex(@"(?:\n){3,}", RegexOptions.CultureInvariant)] + private static partial Regex ExcessBlankLinesRegex(); + + [GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)] + private static partial Regex MultiWhitespaceRegex(); +} diff --git a/GenHub/GenHub.Core/Models/Enums/ContentState.cs b/GenHub/GenHub.Core/Models/Enums/ContentState.cs new file mode 100644 index 000000000..c69305f69 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Enums/ContentState.cs @@ -0,0 +1,23 @@ +namespace GenHub.Core.Models.Enums; + +/// +/// Content state for UI display - determines which button to show. +/// +public enum ContentState +{ + /// + /// Content has not been downloaded yet. Show "Download" button. + /// + NotDownloaded, + + /// + /// Content exists locally but a newer version is available (same publisher+name, newer date). + /// Show "Update" button. + /// + UpdateAvailable, + + /// + /// Content is downloaded and up-to-date. Show "Add to Profile" dropdown. + /// + Downloaded, +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs new file mode 100644 index 000000000..f163c0603 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs @@ -0,0 +1,82 @@ +using System; +using System.Globalization; +using GenHub.Infrastructure.Converters; +using Xunit; + +namespace GenHub.Tests.Core.Converters; + +/// +/// Unit tests for . +/// +public sealed class StripHtmlConverterTests +{ + private readonly StripHtmlConverter _converter = new(); + + /// + /// Verifies Convert strips HTML tags and normalizes text. + /// + [Fact] + public void Convert_WithHtmlMarkup_StripsTags() + { + var input = "

Test content with links.

"; + var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture); + + Assert.Equal("Test content with links.", result); + } + + /// + /// Verifies Convert with integer parameter truncates and single-lines text. + /// + [Fact] + public void Convert_WithMaxLenParameter_CleansToSingleLineAndTruncates() + { + var input = "

First line

\n\n

Second line with a lot of details here.

"; + var result = _converter.Convert(input, typeof(string), 25, CultureInfo.InvariantCulture); + + Assert.Equal("First line Second line...", result); + } + + /// + /// Verifies Convert with string parameter parses integer and truncates. + /// + [Fact] + public void Convert_WithStringParameter_ParsesAndTruncates() + { + var input = "

First line

\n\n

Second line with a lot of details here.

"; + var result = _converter.Convert(input, typeof(string), "25", CultureInfo.InvariantCulture); + + Assert.Equal("First line Second line...", result); + } + + /// + /// Verifies Convert handles non-string input by returning value untouched. + /// + [Fact] + public void Convert_WithScriptAndStyleTags_StripsContents() + { + var input = "

Hello World

"; + var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture); + + Assert.Equal("Hello World", result); + } + + /// + /// Verifies Convert handles non-string input by returning value untouched. + /// + [Fact] + public void Convert_NonStringValue_ReturnsOriginalValue() + { + var result = _converter.Convert(42, typeof(int), null, CultureInfo.InvariantCulture); + Assert.Equal(42, result); + } + + /// + /// Verifies ConvertBack throws NotSupportedException. + /// + [Fact] + public void ConvertBack_ThrowsNotSupportedException() + { + Assert.Throws(() => + _converter.ConvertBack("test", typeof(string), null, CultureInfo.InvariantCulture)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs new file mode 100644 index 000000000..574b95262 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/HtmlTextHelperTests.cs @@ -0,0 +1,147 @@ +using System; +using GenHub.Core.Helpers; +using Xunit; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Unit tests for . +/// +public sealed class HtmlTextHelperTests +{ + /// + /// Verifies that NormalizeHtml returns an empty string when input is null or whitespace. + /// + /// The test input string. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\r\n\t")] + public void NormalizeHtml_NullOrWhitespace_ReturnsEmptyString(string? input) + { + var result = HtmlTextHelper.NormalizeHtml(input); + Assert.Equal(string.Empty, result); + } + + /// + /// Verifies that NormalizeHtml converts paragraph tags into paragraphs separated by newlines. + /// + [Fact] + public void NormalizeHtml_ParagraphTags_ConvertsToParagraphsAndStripsTags() + { + var html = "

First paragraph.

Second paragraph.

"; + var result = HtmlTextHelper.NormalizeHtml(html); + + var expected = $"First paragraph.{Environment.NewLine}{Environment.NewLine}Second paragraph."; + Assert.Equal(expected, result); + } + + /// + /// Verifies that NormalizeHtml converts break tags to line breaks. + /// + [Fact] + public void NormalizeHtml_BreakTags_ConvertsToNewlines() + { + var html = "Line 1
Line 2
Line 3
Line 4"; + var result = HtmlTextHelper.NormalizeHtml(html); + + var expected = $"Line 1{Environment.NewLine}Line 2{Environment.NewLine}Line 3{Environment.NewLine}Line 4"; + Assert.Equal(expected, result); + } + + /// + /// Verifies that NormalizeHtml strips inline HTML formatting tags. + /// + [Fact] + public void NormalizeHtml_InlineTags_StripsTagsCleanly() + { + var html = "Bold Italic Link Text"; + var result = HtmlTextHelper.NormalizeHtml(html); + + Assert.Equal("Bold Italic Link Text", result); + } + + /// + /// Verifies that NormalizeHtml decodes HTML entities into appropriate characters. + /// + [Fact] + public void NormalizeHtml_HtmlEntities_DecodesCorrectly() + { + var html = ""Hello & Welcome's <World>"   –"; + var result = HtmlTextHelper.NormalizeHtml(html); + + Assert.Equal("\"Hello & Welcome's \" –", result); + } + + /// + /// Verifies that NormalizeHtml strips paragraph tags from CNC Labs description snippets. + /// + [Fact] + public void NormalizeHtml_CncLabsDescriptionWithPTags_ResolvesCleanly() + { + var html = "

The Ships and Boats War map is a game map that takes place almost

"; + var result = HtmlTextHelper.NormalizeHtml(html); + + Assert.Equal("The Ships and Boats War map is a game map that takes place almost", result); + } + + /// + /// Verifies that NormalizeHtml collapses runs of excess blank lines to a double newline. + /// + [Fact] + public void NormalizeHtml_ExcessBlankLines_CollapsedToDoubleNewline() + { + var html = "First paragraph\n\n\n\n\nSecond paragraph"; + var result = HtmlTextHelper.NormalizeHtml(html); + + var expected = $"First paragraph{Environment.NewLine}{Environment.NewLine}Second paragraph"; + Assert.Equal(expected, result); + } + + /// + /// Verifies that CleanToSingleLine collapses multiple whitespace characters and newlines into a single space. + /// + [Fact] + public void CleanToSingleLine_WithHtmlAndNewlines_CollapsesWhitespace() + { + var html = "

First line

\n\n

Second line\twith spaces

"; + var result = HtmlTextHelper.CleanToSingleLine(html); + + Assert.Equal("First line Second line with spaces", result); + } + + /// + /// Verifies that CleanToSingleLine truncates strings exceeding maximum length and appends an ellipsis. + /// + [Fact] + public void CleanToSingleLine_WithMaxLength_TruncatesWithEllipsis() + { + var html = "

The Ships and Boats War map is a game map that takes place almost

"; + var result = HtmlTextHelper.CleanToSingleLine(html, 30); + + Assert.Equal(30, result.Length); + Assert.EndsWith("...", result, StringComparison.Ordinal); + Assert.Equal("The Ships and Boats War map...", result); + } + + /// + /// Verifies that TruncateWithEllipsis handles various length inputs and edge cases. + /// + /// The test input string. + /// The maximum allowed length. + /// The expected truncated output. + [Theory] + [InlineData(null, 10, "")] + [InlineData("", 10, "")] + [InlineData("Short text", 20, "Short text")] + [InlineData("ExactLengthText", 15, "ExactLengthText")] + [InlineData("A very long string exceeding limit", 10, "A very ...")] + [InlineData("Abcdef", 3, "Abc")] + [InlineData("Abcdef", 2, "Ab")] + public void TruncateWithEllipsis_VariousInputs_BehavesCorrectly(string? input, int maxLength, string expected) + { + var result = HtmlTextHelper.TruncateWithEllipsis(input, maxLength); + Assert.Equal(expected, result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs index a0aaa1275..e9c2a95be 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/ApplicationDataPathConventionTests.cs @@ -42,6 +42,9 @@ public class ApplicationDataPathConventionTests // Core-layer fallback, overridden at the composition root by ContentPipelineModule. ["ProviderDefinitionLoader.cs"] = "Default only; the DI registration supplies an override.", + + // UI image cache service initialized outside DI container. + ["ImageCacheService.cs"] = "Static singleton image cache initialized outside DI.", }; /// diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs index 666f4a002..c63be62bb 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Converters/StringToImageConverterTests.cs @@ -97,12 +97,12 @@ public void Convert_WithAvarUri_DoesNotReturnNull() } /// - /// Tests that throws . + /// Tests that throws . /// [Fact] - public void ConvertBack_ThrowsNotImplementedException() + public void ConvertBack_ThrowsNotSupportedException() { - Assert.Throws(() => + Assert.Throws(() => _converter.ConvertBack(null, typeof(string), null, _culture)); } } \ No newline at end of file diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs new file mode 100644 index 000000000..40d247a50 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Infrastructure/Services/ImageCacheServiceTests.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Threading.Tasks; +using GenHub.Infrastructure.Services; +using Xunit; + +namespace GenHub.Tests.Core.Infrastructure.Services; + +/// +/// Unit tests for security methods. +/// +public class ImageCacheServiceTests +{ + /// + /// Verifies that private and loopback IPv4/IPv6 addresses are rejected as unsafe. + /// + /// The IP string to test. + [Theory] + [InlineData("127.0.0.1")] + [InlineData("10.0.0.1")] + [InlineData("172.16.0.1")] + [InlineData("172.31.255.255")] + [InlineData("192.168.1.1")] + [InlineData("169.254.1.1")] + [InlineData("100.64.0.1")] + [InlineData("::1")] + [InlineData("fc00::1")] + [InlineData("fe80::1")] + public void IsSafeIpAddress_PrivateOrLoopback_ReturnsFalse(string ipString) + { + var ip = IPAddress.Parse(ipString); + Assert.False(ImageCacheService.IsSafeIpAddress(ip)); + } + + /// + /// Verifies that public routable IP addresses are accepted as safe. + /// + /// The IP string to test. + [Theory] + [InlineData("8.8.8.8")] + [InlineData("1.1.1.1")] + [InlineData("142.250.190.46")] + [InlineData("2606:4700:4700::1111")] + public void IsSafeIpAddress_PublicRoutableIp_ReturnsTrue(string ipString) + { + var ip = IPAddress.Parse(ipString); + Assert.True(ImageCacheService.IsSafeIpAddress(ip)); + } + + /// + /// Verifies that localhost and invalid hostnames are rejected by . + /// + /// The host to test. + /// A task representing the asynchronous test. + [Theory] + [InlineData("localhost")] + [InlineData("127.0.0.1")] + [InlineData("192.168.0.1")] + [InlineData("")] + public async Task IsSafeHostAsync_UnsafeHost_ReturnsFalseAsync(string host) + { + var result = await ImageCacheService.IsSafeHostAsync(host); + Assert.False(result); + } + + /// + /// Verifies that non-HTTP/HTTPS and UNC paths are rejected by . + /// + /// The URL to test. + [Theory] + [InlineData("file:///C:/secret.txt")] + [InlineData("custom://example.com/image.png")] + [InlineData("\\\\server\\share\\image.png")] + [InlineData("javascript:alert(1)")] + [InlineData("https://localhost/test.png")] + [InlineData("https://127.0.0.1/test.png")] + [InlineData("https://192.168.1.1/test.png")] + public void IsSafeRemoteUrl_UnsafeUrl_ReturnsFalse(string url) + { + var result = ImageCacheService.IsSafeRemoteUrl(url, out _); + Assert.False(result); + } + + /// + /// Verifies that valid public HTTP/HTTPS URLs are accepted. + /// + /// The URL to test. + [Theory] + [InlineData("https://example.com/image.png")] + [InlineData("https://cdn.playgenerals.online/images/cover.jpg")] + [InlineData("https://8.8.8.8/image.jpg")] + public void IsSafeRemoteUrl_SafeUrl_ReturnsTrue(string url) + { + var result = ImageCacheService.IsSafeRemoteUrl(url, out var uri); + Assert.True(result); + Assert.NotNull(uri); + } +} diff --git a/GenHub/GenHub/Infrastructure/Controls/ImageLoader.cs b/GenHub/GenHub/Infrastructure/Controls/ImageLoader.cs new file mode 100644 index 000000000..981d253fe --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Controls/ImageLoader.cs @@ -0,0 +1,111 @@ +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Threading; +using GenHub.Infrastructure.Services; + +namespace GenHub.Infrastructure.Controls; + +/// +/// Attached property for asynchronously loading and caching image URLs onto Avalonia Image controls. +/// +public static class ImageLoader +{ + /// + /// Identifies the Source attached property. + /// + public static readonly AttachedProperty SourceProperty = + AvaloniaProperty.RegisterAttached("Source", typeof(ImageLoader)); + + static ImageLoader() + { + SourceProperty.Changed.AddClassHandler(OnSourceChanged); + } + + /// + /// Gets the Source property value. + /// + /// The Image control. + /// The string image URL or path. + public static string? GetSource(Image element) => element.GetValue(SourceProperty); + + /// + /// Sets the Source property value. + /// + /// The Image control. + /// The string image URL or path. + public static void SetSource(Image element, string? value) => element.SetValue(SourceProperty, value); + + private static void OnSourceChanged(Image image, AvaloniaPropertyChangedEventArgs e) + { + image.AttachedToVisualTree -= OnAttachedToVisualTree; + + var url = e.NewValue as string; + if (string.IsNullOrWhiteSpace(url)) + { + image.Source = null; + return; + } + + image.AttachedToVisualTree += OnAttachedToVisualTree; + _ = ApplySourceAsync(image, url); + } + + private static void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + if (sender is not Image image) + { + return; + } + + var url = GetSource(image); + if (string.IsNullOrWhiteSpace(url)) + { + return; + } + + if (image.Source != null) + { + InvalidateImage(image); + return; + } + + _ = ApplySourceAsync(image, url); + } + + private static async Task ApplySourceAsync(Image image, string url) + { + var bitmap = ImageCacheService.Instance.GetBitmapFromMemory(url) + ?? await ImageCacheService.Instance.GetBitmapAsync(url); + + if (bitmap == null || GetSource(image) != url) + { + return; + } + + void SetBitmap() + { + if (GetSource(image) == url) + { + image.Source = bitmap; + InvalidateImage(image); + } + } + + if (Dispatcher.UIThread.CheckAccess()) + { + SetBitmap(); + } + else + { + await Dispatcher.UIThread.InvokeAsync(SetBitmap); + } + } + + private static void InvalidateImage(Image image) + { + image.InvalidateMeasure(); + image.InvalidateArrange(); + image.InvalidateVisual(); + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/BoolToBackgroundConverter.cs b/GenHub/GenHub/Infrastructure/Converters/BoolToBackgroundConverter.cs new file mode 100644 index 000000000..26af067fe --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/BoolToBackgroundConverter.cs @@ -0,0 +1,37 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts a boolean selection state to a background brush for selectable cards. +/// +public class BoolToBackgroundConverter : IValueConverter +{ + private static readonly IBrush Selected = new SolidColorBrush(Color.FromArgb(60, 171, 71, 188)); + private static readonly IBrush Unselected = new SolidColorBrush(Color.Parse("#252525")); + + /// + /// Converts a boolean to the matching background brush. + /// + /// The boolean value to convert. + /// The target type for the conversion. + /// Optional parameter for conversion. + /// The culture to use for conversion. + /// A for the selected or unselected state. + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? Selected : Unselected; + + /// + /// Converts back from a brush to a boolean. Not implemented. + /// + /// The brush value to convert back. + /// The target type for the conversion. + /// Optional parameter for conversion. + /// The culture to use for conversion. + /// This method is not implemented and always throws. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/GenHub/GenHub/Infrastructure/Converters/BoolToBorderConverter.cs b/GenHub/GenHub/Infrastructure/Converters/BoolToBorderConverter.cs new file mode 100644 index 000000000..42d950918 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/BoolToBorderConverter.cs @@ -0,0 +1,37 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts a boolean selection state to a border brush for selectable cards. +/// +public class BoolToBorderConverter : IValueConverter +{ + private static readonly IBrush Selected = new SolidColorBrush(Color.Parse("#AB47BC")); + private static readonly IBrush Unselected = Brushes.Transparent; + + /// + /// Converts a boolean to the matching border brush. + /// + /// The boolean value to convert. + /// The target type for the conversion. + /// Optional parameter for conversion. + /// The culture to use for conversion. + /// A for the selected or unselected state. + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is true ? Selected : Unselected; + + /// + /// Converts back from a brush to a boolean. Not implemented. + /// + /// The brush value to convert back. + /// The target type for the conversion. + /// Optional parameter for conversion. + /// The culture to use for conversion. + /// This method is not implemented and always throws. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/GenHub/GenHub/Infrastructure/Converters/ComparisonConverters.cs b/GenHub/GenHub/Infrastructure/Converters/ComparisonConverters.cs index 30dabbd50..42c110dee 100644 --- a/GenHub/GenHub/Infrastructure/Converters/ComparisonConverters.cs +++ b/GenHub/GenHub/Infrastructure/Converters/ComparisonConverters.cs @@ -36,6 +36,11 @@ public static class ComparisonConverters public static readonly IValueConverter IsPositive = new FuncValueConverter( count => count > 0); + /// + /// A value converter that returns true if the value is not equal to the converter parameter. + /// + public static readonly IValueConverter IsNotEqualTo = new NotEqualToConverter(); + private static bool TryGetDouble(object? value, out double result) { if (value == null) diff --git a/GenHub/GenHub/Infrastructure/Converters/ContentStateToBrushConverter.cs b/GenHub/GenHub/Infrastructure/Converters/ContentStateToBrushConverter.cs new file mode 100644 index 000000000..08fd8c005 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/ContentStateToBrushConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; + +namespace GenHub.Infrastructure.Converters; + +/// +/// converts a content state enum value to a corresponding status brush. +/// +public class ContentStateToBrushConverter : IValueConverter +{ + private static readonly IBrush DownloadedBrush = new SolidColorBrush(Color.Parse(UiConstants.StatusDownloadedColor)); + private static readonly IBrush NotDownloadedBrush = new SolidColorBrush(Color.Parse(UiConstants.StatusNotDownloadedColor)); + private static readonly IBrush UpdateAvailableBrush = new SolidColorBrush(Color.Parse(UiConstants.StatusUpdateAvailableColor)); + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is ContentState state) + { + return state switch + { + ContentState.Downloaded => DownloadedBrush, + ContentState.UpdateAvailable => UpdateAvailableBrush, + ContentState.NotDownloaded => NotDownloadedBrush, + _ => NotDownloadedBrush, + }; + } + + return NotDownloadedBrush; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/ContentStateToPathDataConverter.cs b/GenHub/GenHub/Infrastructure/Converters/ContentStateToPathDataConverter.cs new file mode 100644 index 000000000..c24830e71 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/ContentStateToPathDataConverter.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Data.Converters; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; + +namespace GenHub.Infrastructure.Converters; + +/// +/// converts a content state enum value to svg path data for vector icon rendering. +/// +public class ContentStateToPathDataConverter : IValueConverter +{ + private static readonly Dictionary IconPaths = new() + { + [ContentState.Downloaded] = UiConstants.TransparentCheckmarkIconPath, + [ContentState.NotDownloaded] = UiConstants.DownloadArrowIconPath, + [ContentState.UpdateAvailable] = UiConstants.UpdateSyncIconPath, + }; + + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is ContentState state && IconPaths.TryGetValue(state, out var path)) + { + return path; + } + + return UiConstants.DownloadArrowIconPath; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/ContentStateToTextConverter.cs b/GenHub/GenHub/Infrastructure/Converters/ContentStateToTextConverter.cs new file mode 100644 index 000000000..ef7d0097f --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/ContentStateToTextConverter.cs @@ -0,0 +1,32 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using GenHub.Core.Models.Enums; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts a enum value to a compact emoji indicator +/// suitable for space-constrained UI like the variant dropdown. +/// +public class ContentStateToTextConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is ContentState state + ? state switch + { + ContentState.Downloaded => "✅", + ContentState.UpdateAvailable => "🔄", + _ => "⇩", + } + : "⇩"; + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/GameTypeInitialConverter.cs b/GenHub/GenHub/Infrastructure/Converters/GameTypeInitialConverter.cs new file mode 100644 index 000000000..008240bbf --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/GameTypeInitialConverter.cs @@ -0,0 +1,45 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts a game type value (enum or its string representation) to a short initial for display. +/// +public class GameTypeInitialConverter : IValueConverter +{ + /// + /// Converts a game type value to its display initial. + /// + /// The game type value to convert. + /// The target type for the conversion. + /// Optional parameter for conversion. + /// The culture to use for conversion. + /// A short string initial representing the game type. + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var text = value?.ToString(); + + if (string.IsNullOrEmpty(text)) + return "?"; + + return text switch + { + "ZeroHour" => "ZH", + "Generals" => "G", + _ => text[..1].ToUpperInvariant(), + }; + } + + /// + /// Converts back from an initial to a game type. Not implemented. + /// + /// The value produced by the binding target. + /// The type to convert to. + /// The converter parameter to use. + /// The culture to use in the converter. + /// This conversion is not supported and always throws. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotImplementedException(); +} diff --git a/GenHub/GenHub/Infrastructure/Converters/IndentToMarginConverter.cs b/GenHub/GenHub/Infrastructure/Converters/IndentToMarginConverter.cs new file mode 100644 index 000000000..88d3fe0ab --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/IndentToMarginConverter.cs @@ -0,0 +1,40 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts an integer indent level to a left-margin Thickness for nested items. +/// +public class IndentToMarginConverter : IValueConverter +{ + /// + /// Converts indent level to Thickness. + /// + /// The indent level integer. + /// Target binding type. + /// Converter parameter. + /// Culture info. + /// A Thickness value for left margin indentation. + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var indent = value is int level ? level : 0; + var indentPixels = Math.Min(indent, 5) * 24; + return new Thickness(indentPixels, 0, 0, 8); + } + + /// + /// Not supported for one-way conversion. + /// + /// The target value. + /// Target binding type. + /// Converter parameter. + /// Culture info. + /// Always throws NotSupportedException. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/NotEqualToConverter.cs b/GenHub/GenHub/Infrastructure/Converters/NotEqualToConverter.cs new file mode 100644 index 000000000..e48a82f42 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/NotEqualToConverter.cs @@ -0,0 +1,38 @@ +using Avalonia.Data.Converters; +using System; +using System.Globalization; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converter that returns true if the value is not equal to the parameter. +/// +internal sealed class NotEqualToConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value == null && parameter == null) + { + return false; + } + + if (value == null || parameter == null) + { + return true; + } + + return !value.Equals(parameter); + } + + /// + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool b && !b) + { + return parameter; + } + + return Avalonia.Data.BindingOperations.DoNothing; + } +} diff --git a/GenHub/GenHub/Infrastructure/Converters/StringToImageConverter.cs b/GenHub/GenHub/Infrastructure/Converters/StringToImageConverter.cs index 8856bb967..47b15602b 100644 --- a/GenHub/GenHub/Infrastructure/Converters/StringToImageConverter.cs +++ b/GenHub/GenHub/Infrastructure/Converters/StringToImageConverter.cs @@ -53,12 +53,18 @@ public class StringToImageConverter : IValueConverter if (path.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || path.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { - // TODO: For web URLs, implement caching/downloading if needed + var cached = Services.ImageCacheService.Instance.GetBitmapFromMemory(path); + if (cached != null) + { + return cached; + } + + _ = Services.ImageCacheService.Instance.GetBitmapAsync(path); return null; } - // Handle local file paths - if (Path.IsPathRooted(path) && File.Exists(path)) + // Handle local file paths (reject UNC shares) + if (Path.IsPathRooted(path) && !path.StartsWith(@"\\", StringComparison.Ordinal) && !path.StartsWith("//", StringComparison.Ordinal) && File.Exists(path)) { return new Bitmap(path); } @@ -73,13 +79,13 @@ public class StringToImageConverter : IValueConverter } /// - /// Not implemented. Converts a Bitmap back to a string file path. + /// Not supported. Converts a Bitmap back to a string file path. /// /// - /// This method does not return a value; it always throws . - /// Always thrown as this converter only supports one-way conversion. + /// This method does not return a value; it always throws . + /// Always thrown as this converter only supports one-way conversion. public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) { - throw new NotImplementedException(); + throw new NotSupportedException(); } } diff --git a/GenHub/GenHub/Infrastructure/Converters/StripHtmlConverter.cs b/GenHub/GenHub/Infrastructure/Converters/StripHtmlConverter.cs new file mode 100644 index 000000000..6870e1199 --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Converters/StripHtmlConverter.cs @@ -0,0 +1,39 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using GenHub.Core.Helpers; + +namespace GenHub.Infrastructure.Converters; + +/// +/// Converts a string containing HTML markup to clean, normalized plain text. +/// Optionally accepts a maximum length integer as parameter for single-line truncated conversion. +/// +public class StripHtmlConverter : IValueConverter +{ + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string text) + { + return value; + } + + if (parameter is int maxLen) + { + return HtmlTextHelper.CleanToSingleLine(text, maxLen); + } + + if (parameter is string paramStr && int.TryParse(paramStr, CultureInfo.InvariantCulture, out var parsedMax)) + { + return HtmlTextHelper.CleanToSingleLine(text, parsedMax); + } + + return HtmlTextHelper.NormalizeHtml(text); + } + + /// + /// Always thrown as two-way binding is not supported. + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs b/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs new file mode 100644 index 000000000..f447440fa --- /dev/null +++ b/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs @@ -0,0 +1,613 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; +using GenHub.Core.Constants; + +namespace GenHub.Infrastructure.Services; + +/// +/// Thread-safe service for downloading and caching web images in memory and on disk. +/// +public sealed class ImageCacheService +{ + private static readonly Lazy InstanceLazy = new(() => new ImageCacheService()); + private readonly LruMemoryCache memoryCache = new(ImageCacheConstants.MaxMemoryCacheEntries); + private readonly ConcurrentDictionary> pendingDownloads = new(StringComparer.OrdinalIgnoreCase); + private readonly HttpClient httpClient; + private readonly string cacheDirectory; + private readonly object diskCleanupLock = new(); + private DateTime lastDiskCleanup = DateTime.MinValue; + + /// + /// Gets the singleton instance of . + /// + public static ImageCacheService Instance => InstanceLazy.Value; + + private ImageCacheService() + { + var handler = new SocketsHttpHandler + { + AllowAutoRedirect = false, + PooledConnectionLifetime = TimeSpan.FromMinutes(5), + ConnectTimeout = TimeSpan.FromSeconds(10), + }; + + httpClient = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(ImageCacheConstants.DefaultTimeoutSeconds), + }; + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"); + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + cacheDirectory = Path.Combine(appData, "GenHub", DirectoryNames.Cache, "Images"); + Directory.CreateDirectory(cacheDirectory); + } + + /// + /// Validates whether an IP address is a safe public IP address (not loopback, private, link-local, carrier-grade NAT, or reserved). + /// + /// The IP address to evaluate. + /// if the IP address is safe; otherwise, . + public static bool IsSafeIpAddress(IPAddress ip) + { + if (IPAddress.IsLoopback(ip) || ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal) + { + return false; + } + + if (ip.IsIPv4MappedToIPv6) + { + ip = ip.MapToIPv4(); + } + + var bytes = ip.GetAddressBytes(); + if (bytes.Length == 4) + { + // 0.0.0.0/8 + if (bytes[0] == 0) return false; + + // 10.0.0.0/8 + if (bytes[0] == 10) return false; + + // 100.64.0.0/10 (Carrier-grade NAT) + if (bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127) return false; + + // 127.0.0.0/8 + if (bytes[0] == 127) return false; + + // 169.254.0.0/16 (Link-local) + if (bytes[0] == 169 && bytes[1] == 254) return false; + + // 172.16.0.0/12 + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return false; + + // 192.168.0.0/16 + if (bytes[0] == 192 && bytes[1] == 168) return false; + } + else if (bytes.Length == 16) + { + // Unique local address (fc00::/7) + if ((bytes[0] & 0xfe) == 0xfc) return false; + + // Link-local address (fe80::/10) + if (bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80) return false; + } + + return true; + } + + /// + /// Asynchronously validates that a host does not resolve to private or loopback IP addresses. + /// + /// The host name or IP string to evaluate. + /// Cancellation token. + /// if all resolved IP addresses are safe; otherwise, . + public static async Task IsSafeHostAsync(string host, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(host) || string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (IPAddress.TryParse(host, out var ip)) + { + return IsSafeIpAddress(ip); + } + + try + { + var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken); + if (addresses.Length == 0) + { + return false; + } + + foreach (var addr in addresses) + { + if (!IsSafeIpAddress(addr)) + { + return false; + } + } + + return true; + } + catch + { + return false; + } + } + + /// + /// Validates whether a remote URL is a safe public HTTP or HTTPS endpoint. + /// Rejects local paths, UNC shares, loopback addresses, link-local addresses, and private networks. + /// + /// The URL string to evaluate. + /// When valid, receives the parsed . + /// if the URL meets the security criteria; otherwise, . + public static bool IsSafeRemoteUrl(string? url, out Uri? uri) + { + uri = null; + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + if (!Uri.TryCreate(url, UriKind.Absolute, out var parsedUri)) + { + return false; + } + + if (parsedUri.Scheme != Uri.UriSchemeHttp && parsedUri.Scheme != Uri.UriSchemeHttps) + { + return false; + } + + if (parsedUri.IsFile || parsedUri.IsUnc) + { + return false; + } + + var host = parsedUri.Host; + if (string.IsNullOrWhiteSpace(host) || + parsedUri.IsLoopback || + string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (IPAddress.TryParse(host, out var ip) && !IsSafeIpAddress(ip)) + { + return false; + } + + uri = parsedUri; + return true; + } + + /// + /// Synchronously checks if a bitmap is already cached in memory. + /// + /// The image URL. + /// The cached if present; otherwise, . + public Bitmap? GetBitmapFromMemory(string url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return null; + } + + return memoryCache.TryGet(url); + } + + /// + /// Asynchronously gets a bitmap from memory, disk cache, or web. + /// + /// The image URL. + /// Cancellation token. + /// The loaded , or if loading failed. + public async Task GetBitmapAsync(string url, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(url)) + { + return null; + } + + var cached = memoryCache.TryGet(url); + if (cached != null) + { + return cached; + } + + // Handle avares:// URIs (embedded resources) + if (url.StartsWith("avares://", StringComparison.OrdinalIgnoreCase)) + { + try + { + var uri = new Uri(url); + if (AssetLoader.Exists(uri)) + { + using var stream = AssetLoader.Open(uri); + var bitmap = new Bitmap(stream); + memoryCache.AddOrUpdate(url, bitmap); + return bitmap; + } + } + catch + { + // ignore asset loading error and fall through + } + + return null; + } + + // Handle relative asset paths (e.g., "/Assets/Logos/logo.png") + if (url.StartsWith("/", StringComparison.Ordinal)) + { + try + { + var uri = new Uri($"avares://GenHub{url}"); + if (AssetLoader.Exists(uri)) + { + using var stream = AssetLoader.Open(uri); + var bitmap = new Bitmap(stream); + memoryCache.AddOrUpdate(url, bitmap); + return bitmap; + } + } + catch + { + // ignore asset loading error and fall through + } + + return null; + } + + // Handle asset paths starting with 'Assets/' + if (url.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + try + { + var uri = new Uri($"avares://GenHub/{url}"); + if (AssetLoader.Exists(uri)) + { + using var stream = AssetLoader.Open(uri); + var bitmap = new Bitmap(stream); + memoryCache.AddOrUpdate(url, bitmap); + return bitmap; + } + } + catch + { + // ignore asset loading error and fall through + } + + return null; + } + + // Validate safe remote HTTP/HTTPS endpoint. Untrusted local paths and UNC shares are rejected. + if (!IsSafeRemoteUrl(url, out _)) + { + return null; + } + + var diskPath = GetDiskCachePath(url); + if (File.Exists(diskPath)) + { + try + { + var diskBitmap = new Bitmap(diskPath); + memoryCache.AddOrUpdate(url, diskBitmap); + return diskBitmap; + } + catch + { + try + { + File.Delete(diskPath); + } + catch + { + // ignore file deletion failure + } + } + } + + var downloadTask = pendingDownloads.GetOrAdd(url, u => DownloadAndCacheAsync(u, diskPath)); + try + { + return await downloadTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return null; + } + } + + private async Task DownloadAndCacheAsync(string initialUrl, string diskPath) + { + try + { + var currentUrl = initialUrl; + HttpResponseMessage? response = null; + + for (int redirectCount = 0; redirectCount <= ImageCacheConstants.MaxRedirects; redirectCount++) + { + if (!IsSafeRemoteUrl(currentUrl, out var targetUri) || targetUri == null) + { + return null; + } + + var isSafeHost = await IsSafeHostAsync(targetUri.Host); + if (!isSafeHost) + { + return null; + } + + var request = new HttpRequestMessage(HttpMethod.Get, currentUrl); + request.Headers.Add("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"); + if (currentUrl.Contains("moddb.com", StringComparison.OrdinalIgnoreCase)) + { + request.Headers.Referrer = new Uri("https://www.moddb.com/"); + } + + response?.Dispose(); + response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + + if ((int)response.StatusCode >= 300 && (int)response.StatusCode <= 399) + { + var redirectLocation = response.Headers.Location; + if (redirectLocation == null) + { + return null; + } + + var nextUri = redirectLocation.IsAbsoluteUri + ? redirectLocation + : new Uri(targetUri, redirectLocation); + + currentUrl = nextUri.ToString(); + continue; + } + + break; + } + + if (response == null || !response.IsSuccessStatusCode) + { + response?.Dispose(); + return null; + } + + using (response) + { + var mediaType = response.Content.Headers.ContentType?.MediaType; + if (!string.IsNullOrEmpty(mediaType) && + !mediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase) && + !mediaType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (response.Content.Headers.ContentLength is long len && len > ImageCacheConstants.MaxImageDownloadSizeBytes) + { + return null; + } + + using var responseStream = await response.Content.ReadAsStreamAsync(); + using var ms = new MemoryStream(); + var buffer = new byte[81920]; + long totalRead = 0; + int read = 0; + + while ((read = await responseStream.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + totalRead += read; + if (totalRead > ImageCacheConstants.MaxImageDownloadSizeBytes) + { + return null; + } + + await ms.WriteAsync(buffer.AsMemory(0, read)); + } + + if (ms.Length == 0) + { + return null; + } + + var bytes = ms.ToArray(); + await File.WriteAllBytesAsync(diskPath, bytes); + + using var decodeStream = new MemoryStream(bytes); + var bitmap = new Bitmap(decodeStream); + + memoryCache.AddOrUpdate(initialUrl, bitmap); + TriggerDiskCleanupIfNeeded(); + return bitmap; + } + } + catch + { + return null; + } + finally + { + pendingDownloads.TryRemove(initialUrl, out _); + } + } + + private string GetDiskCachePath(string url) + { + var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(url)); + var sb = new StringBuilder(); + foreach (var b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + + return Path.Combine(cacheDirectory, sb.ToString() + ".img"); + } + + private void TriggerDiskCleanupIfNeeded() + { + if (DateTime.UtcNow - lastDiskCleanup < TimeSpan.FromHours(1)) + { + return; + } + + _ = Task.Run(() => + { + lock (diskCleanupLock) + { + if (DateTime.UtcNow - lastDiskCleanup < TimeSpan.FromHours(1)) + { + return; + } + + lastDiskCleanup = DateTime.UtcNow; + + try + { + if (!Directory.Exists(cacheDirectory)) + { + return; + } + + var di = new DirectoryInfo(cacheDirectory); + var files = di.GetFiles("*.img"); + var cutoff = DateTime.UtcNow.AddDays(-ImageCacheConstants.DiskCacheTtlDays); + long totalSize = 0; + + var fileList = new List(); + foreach (var file in files) + { + if (file.LastWriteTimeUtc < cutoff) + { + try + { + file.Delete(); + } + catch + { + // ignore cleanup failure + } + } + else + { + fileList.Add(file); + totalSize += file.Length; + } + } + + if (totalSize > ImageCacheConstants.MaxDiskCacheSizeBytes) + { + var sorted = fileList.OrderBy(f => f.LastWriteTimeUtc).ToList(); + foreach (var file in sorted) + { + if (totalSize <= ImageCacheConstants.MaxDiskCacheSizeBytes * 0.8) + { + break; + } + + try + { + totalSize -= file.Length; + file.Delete(); + } + catch + { + // ignore cleanup failure + } + } + } + } + catch + { + // ignore disk cleanup failures + } + } + }); + } + + /// + /// Thread-safe bounded LRU memory cache for bitmaps. + /// + private sealed class LruMemoryCache(int maxCapacity) + { + private readonly Dictionary> cache = new(StringComparer.OrdinalIgnoreCase); + private readonly LinkedList lruList = new(); + private readonly object syncLock = new(); + + public Bitmap? TryGet(string key) + { + lock (syncLock) + { + if (cache.TryGetValue(key, out var node)) + { + lruList.Remove(node); + lruList.AddFirst(node); + return node.Value.Bitmap; + } + + return null; + } + } + + public void AddOrUpdate(string key, Bitmap bitmap) + { + lock (syncLock) + { + if (cache.TryGetValue(key, out var existingNode)) + { + lruList.Remove(existingNode); + existingNode.Value = new CacheItem(key, bitmap); + lruList.AddFirst(existingNode); + } + else + { + if (cache.Count >= maxCapacity) + { + var last = lruList.Last; + if (last != null) + { + lruList.RemoveLast(); + cache.Remove(last.Value.Key); + } + } + + var node = new LinkedListNode(new CacheItem(key, bitmap)); + lruList.AddFirst(node); + cache[key] = node; + } + } + } + + public void Clear() + { + lock (syncLock) + { + cache.Clear(); + lruList.Clear(); + } + } + + private readonly record struct CacheItem(string Key, Bitmap Bitmap); + } +} diff --git a/scripts/build-check.ps1 b/scripts/build-check.ps1 new file mode 100644 index 000000000..445075c43 --- /dev/null +++ b/scripts/build-check.ps1 @@ -0,0 +1,249 @@ +<# +.SYNOPSIS + Serialized build/check script for GenHub. Prevents build conflicts when + multiple agents work simultaneously and avoids builds during debugging. + +.DESCRIPTION + Uses a named mutex to ensure only one build runs at a time. + Detects active debugger (devenv lock on output DLLs) and refuses to build. + Supports a lightweight "check" mode that only compiles without producing output. + +.PARAMETER Mode + "check" - Lightweight: compile-only, no output, fastest (default) + "build" - Full build with output + "restore" - NuGet restore only + +.PARAMETER Project + Specific .csproj to check. Defaults to the full solution. + Pass a project path relative to the GenHub solution folder for faster checks. + Example: "GenHub.Core/GenHub.Core.csproj" + +.PARAMETER TimeoutSeconds + Max seconds to wait for the build mutex. Default: 120 + +.PARAMETER Verbosity + MSBuild verbosity: quiet, minimal, normal, detailed. Default: quiet + +.EXAMPLE + # Quick error check on the full solution + .\scripts\build-check.ps1 + +.EXAMPLE + # Quick error check on a single project + .\scripts\build-check.ps1 -Project "GenHub.Core/GenHub.Core.csproj" + +.EXAMPLE + # Full build (serialized, safe) + .\scripts\build-check.ps1 -Mode build + +.EXAMPLE + # Check with longer timeout + .\scripts\build-check.ps1 -TimeoutSeconds 300 +#> + +param( + [ValidateSet("check", "build", "restore")] + [string]$Mode = "check", + + [string]$Project = "", + + [int]$TimeoutSeconds = 120, + + [ValidateSet("quiet", "minimal", "normal", "detailed")] + [string]$Verbosity = "quiet" +) + +$ErrorActionPreference = "Stop" + +# ── Constants ────────────────────────────────────────────────────────────────── +$MutexName = "Global\GenHub_Build_Mutex" +$SolutionDir = Join-Path (Join-Path $PSScriptRoot "..") "GenHub" +$SolutionFile = Join-Path $SolutionDir "GenHub.sln" +$LockFileName = "build.lock" +$LockFilePath = Join-Path $SolutionDir $LockFileName + +# ── Helper functions ─────────────────────────────────────────────────────────── + +function Write-Status { + param([string]$Message, [string]$Color = "Cyan") + Write-Host "[build-check] " -ForegroundColor DarkGray -NoNewline + Write-Host $Message -ForegroundColor $Color +} + +function Write-Err { + param([string]$Message) + Write-Host "[build-check] " -ForegroundColor DarkGray -NoNewline + Write-Host "ERROR: $Message" -ForegroundColor Red +} + +function Test-DebuggerActive { + <# + .SYNOPSIS + Detects if Visual Studio is debugging GenHub by checking for file locks + on the output DLLs in bin/Debug directories. + #> + + # Check for devenv.exe processes that hold locks + $devenvProcesses = Get-Process -Name "devenv" -ErrorAction SilentlyContinue + if (-not $devenvProcesses) { + return $false + } + + # Check if GenHub output DLLs are locked (indicates active debugging) + $binDebugDirs = Get-ChildItem -Path $SolutionDir -Directory -Recurse -Filter "Debug" | + Where-Object { $_.Parent.Name -eq "bin" } + + foreach ($dir in $binDebugDirs) { + $dlls = Get-ChildItem -Path $dir.FullName -Filter "GenHub*.dll" -ErrorAction SilentlyContinue + foreach ($dll in $dlls) { + try { + # Try to open exclusively - if it fails, the file is locked (debugger) + $stream = [System.IO.File]::Open($dll.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + $stream.Close() + $stream.Dispose() + } + catch { + # File is locked - debugger is likely active + return $true + } + } + } + + return $false +} + +function Get-BuildTarget { + if ($Project) { + $projectPath = Join-Path $SolutionDir $Project + if (-not (Test-Path $projectPath)) { + Write-Err "Project not found: $projectPath" + exit 1 + } + return $projectPath + } + return $SolutionFile +} + +# ── Pre-flight checks ───────────────────────────────────────────────────────── + +if (-not (Test-Path $SolutionFile)) { + Write-Err "Solution not found at: $SolutionFile" + exit 1 +} + +# Check for debugger +if (Test-DebuggerActive) { + Write-Err "Visual Studio debugger appears to be active (output DLLs are locked)." + Write-Err "Cannot build while debugging. Detach the debugger first." + exit 2 +} + +# ── Acquire mutex ────────────────────────────────────────────────────────────── + +$mutex = $null +$acquired = $false + +try { + Write-Status "Acquiring build lock (timeout: ${TimeoutSeconds}s)..." + + $mutex = [System.Threading.Mutex]::new($false, $MutexName) + try { + $acquired = $mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds)) + } + catch [System.Threading.AbandonedMutexException] { + $acquired = $true + } + + if (-not $acquired) { + Write-Err "Timed out waiting for build lock after ${TimeoutSeconds}s." + Write-Err "Another agent or process is currently building." + exit 3 + } + + # Write lock file for visibility + $lockInfo = @{ + pid = $PID + mode = $Mode + project = if ($Project) { $Project } else { "GenHub.sln" } + startedAt = (Get-Date -Format "o") + agent = $env:AGENT_NAME + } | ConvertTo-Json -Compress + Set-Content -Path $LockFilePath -Value $lockInfo -Force + + Write-Status "Build lock acquired." "Green" + + # ── Execute build ────────────────────────────────────────────────────────── + + $target = Get-BuildTarget + $exitCode = 0 + + switch ($Mode) { + "check" { + Write-Status "Running compile check on: $(Split-Path $target -Leaf)" + + # Use --no-restore to skip package resolution (much faster) + # Use --no-dependencies when checking a single project (skip transitive) + $args = @( + "build", $target, + "--no-restore", + "--nologo", + "--verbosity", $Verbosity, + "-maxcpucount:2" + ) + + if ($Project) { + $args += "--no-dependencies" + } + + & dotnet @args + $exitCode = $LASTEXITCODE + } + + "build" { + Write-Status "Running full build on: $(Split-Path $target -Leaf)" + + $args = @( + "build", $target, + "--nologo", + "--verbosity", $Verbosity, + "-maxcpucount:2" + ) + + & dotnet @args + $exitCode = $LASTEXITCODE + } + + "restore" { + Write-Status "Running NuGet restore on: $(Split-Path $target -Leaf)" + + & dotnet restore $target --verbosity $Verbosity + $exitCode = $LASTEXITCODE + } + } + + # ── Report result ────────────────────────────────────────────────────────── + + if ($exitCode -eq 0) { + Write-Status "Completed successfully with no errors." "Green" + } + else { + Write-Err "Build/check failed with exit code: $exitCode" + } + + exit $exitCode +} +finally { + # Clean up lock file + if (Test-Path $LockFilePath) { + Remove-Item $LockFilePath -Force -ErrorAction SilentlyContinue + } + + # Release mutex + if ($acquired -and $mutex) { + $mutex.ReleaseMutex() + } + + if ($mutex) { + $mutex.Dispose() + } +} From ba457296c406a5ca5243a5c3896ee9201b2c1cbb Mon Sep 17 00:00:00 2001 From: undead2146 Date: Wed, 19 Aug 2026 14:16:05 +0200 Subject: [PATCH 2/2] fix(imagecache): harden cache lifecycle, validate DNS, enforce TTL, and use SHA-256 key hashing --- .../Services/ImageCacheService.cs | 98 +++++++++++++++---- 1 file changed, 81 insertions(+), 17 deletions(-) diff --git a/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs b/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs index f447440fa..4ea7f00f7 100644 --- a/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs +++ b/GenHub/GenHub/Infrastructure/Services/ImageCacheService.cs @@ -41,6 +41,24 @@ private ImageCacheService() AllowAutoRedirect = false, PooledConnectionLifetime = TimeSpan.FromMinutes(5), ConnectTimeout = TimeSpan.FromSeconds(10), + ConnectCallback = async (context, cancellationToken) => + { + var entry = await Dns.GetHostEntryAsync(context.DnsEndPoint.Host, cancellationToken); + var safeIp = entry.AddressList.FirstOrDefault(IsSafeIpAddress) + ?? throw new HttpRequestException($"No safe IP address resolved for host '{context.DnsEndPoint.Host}'"); + + var socket = new System.Net.Sockets.Socket(safeIp.AddressFamily, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp); + try + { + await socket.ConnectAsync(new IPEndPoint(safeIp, context.DnsEndPoint.Port), cancellationToken); + return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + }, }; httpClient = new HttpClient(handler) @@ -50,9 +68,16 @@ private ImageCacheService() httpClient.DefaultRequestHeaders.UserAgent.ParseAdd( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"); - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - cacheDirectory = Path.Combine(appData, "GenHub", DirectoryNames.Cache, "Images"); - Directory.CreateDirectory(cacheDirectory); + try + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + cacheDirectory = Path.Combine(appData, "GenHub", DirectoryNames.Cache, "Images"); + Directory.CreateDirectory(cacheDirectory); + } + catch + { + cacheDirectory = string.Empty; + } } /// @@ -304,15 +329,11 @@ public static bool IsSafeRemoteUrl(string? url, out Uri? uri) } var diskPath = GetDiskCachePath(url); - if (File.Exists(diskPath)) + if (!string.IsNullOrEmpty(diskPath) && File.Exists(diskPath)) { - try - { - var diskBitmap = new Bitmap(diskPath); - memoryCache.AddOrUpdate(url, diskBitmap); - return diskBitmap; - } - catch + var fileInfo = new FileInfo(diskPath); + var cutoff = DateTime.UtcNow.AddDays(-ImageCacheConstants.DiskCacheTtlDays); + if (fileInfo.LastWriteTimeUtc < cutoff) { try { @@ -323,6 +344,26 @@ public static bool IsSafeRemoteUrl(string? url, out Uri? uri) // ignore file deletion failure } } + else + { + try + { + var diskBitmap = new Bitmap(diskPath); + memoryCache.AddOrUpdate(url, diskBitmap); + return diskBitmap; + } + catch + { + try + { + File.Delete(diskPath); + } + catch + { + // ignore file deletion failure + } + } + } } var downloadTask = pendingDownloads.GetOrAdd(url, u => DownloadAndCacheAsync(u, diskPath)); @@ -433,7 +474,10 @@ public static bool IsSafeRemoteUrl(string? url, out Uri? uri) } var bytes = ms.ToArray(); - await File.WriteAllBytesAsync(diskPath, bytes); + if (!string.IsNullOrEmpty(diskPath)) + { + await File.WriteAllBytesAsync(diskPath, bytes); + } using var decodeStream = new MemoryStream(bytes); var bitmap = new Bitmap(decodeStream); @@ -449,14 +493,22 @@ public static bool IsSafeRemoteUrl(string? url, out Uri? uri) } finally { - pendingDownloads.TryRemove(initialUrl, out _); + if (pendingDownloads.TryGetValue(initialUrl, out var task) && task.IsCompleted) + { + pendingDownloads.TryRemove(initialUrl, out _); + } } } private string GetDiskCachePath(string url) { - var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(url)); - var sb = new StringBuilder(); + if (string.IsNullOrEmpty(cacheDirectory)) + { + return string.Empty; + } + + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(url)); + var sb = new StringBuilder(hashBytes.Length * 2); foreach (var b in hashBytes) { sb.Append(b.ToString("x2")); @@ -467,7 +519,7 @@ private string GetDiskCachePath(string url) private void TriggerDiskCleanupIfNeeded() { - if (DateTime.UtcNow - lastDiskCleanup < TimeSpan.FromHours(1)) + if (string.IsNullOrEmpty(cacheDirectory) || DateTime.UtcNow - lastDiskCleanup < TimeSpan.FromHours(1)) { return; } @@ -528,8 +580,9 @@ private void TriggerDiskCleanupIfNeeded() try { - totalSize -= file.Length; + var fileLen = file.Length; file.Delete(); + totalSize -= fileLen; } catch { @@ -577,6 +630,11 @@ public void AddOrUpdate(string key, Bitmap bitmap) if (cache.TryGetValue(key, out var existingNode)) { lruList.Remove(existingNode); + if (!ReferenceEquals(existingNode.Value.Bitmap, bitmap)) + { + existingNode.Value.Bitmap.Dispose(); + } + existingNode.Value = new CacheItem(key, bitmap); lruList.AddFirst(existingNode); } @@ -589,6 +647,7 @@ public void AddOrUpdate(string key, Bitmap bitmap) { lruList.RemoveLast(); cache.Remove(last.Value.Key); + last.Value.Bitmap.Dispose(); } } @@ -603,6 +662,11 @@ public void Clear() { lock (syncLock) { + foreach (var item in lruList) + { + item.Bitmap.Dispose(); + } + cache.Clear(); lruList.Clear(); }