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., &, ", >, ).
+ /// - 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(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ScriptTagRegex();
+
+ [GeneratedRegex(@"", 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(@"?(?:div|li|h[1-6]|tr|section|article|blockquote|header|footer|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex BlockCloseTagRegex();
+
+ [GeneratedRegex(@"?[A-Za-z][^>]*>", 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\nSecond 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\nSecond 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\nSecond 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();
}