using Microsoft.Extensions.Caching.Memory; using Nuuru.Server.Models; using SixLabors.Fonts; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Drawing.Processing; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace Nuuru.Server.Services { public interface IBuiltInCaptchaService { Task GenerateChallengeAsync(); string? VerifyAnswer(string guid, string answer); } public class BuiltInCaptchaChallenge { public string Guid { get; set; } = string.Empty; public string Base64Image { get; set; } = string.Empty; } public class BuiltInCaptchaService : ICaptchaService, IBuiltInCaptchaService { private readonly ILogger _logger; private readonly IMemoryCache _cache; private readonly ICaptchaTokenService _tokenService; private readonly ISiteSettingsService _siteSettings; public BuiltInCaptchaService( ILogger logger, IMemoryCache cache, ICaptchaTokenService tokenService, ISiteSettingsService siteSettings) { _logger = logger; _cache = cache; _tokenService = tokenService; _siteSettings = siteSettings; } public Task ValidateAsync(string token) { return Task.FromResult(_tokenService.ValidateToken(token)); } public async Task GenerateChallengeAsync() { var imageWidth = await _siteSettings.GetCaptchaSettingIntAsync( SiteSettingKeys.CaptchaBuiltInImageWidth, SiteSettingKeys.DefaultCaptchaBuiltInImageWidth); var imageHeight = await _siteSettings.GetCaptchaSettingIntAsync( SiteSettingKeys.CaptchaBuiltInImageHeight, SiteSettingKeys.DefaultCaptchaBuiltInImageHeight); var challengeTtl = await _siteSettings.GetCaptchaSettingIntAsync( SiteSettingKeys.CaptchaBuiltInChallengeTtlSeconds, SiteSettingKeys.DefaultCaptchaBuiltInChallengeTtlSeconds); var rng = Random.Shared; var a = rng.Next(1, 20); var b = rng.Next(1, 20); var answer = a + b; var questionText = $"{a} + {b} = ?"; var guid = System.Guid.NewGuid().ToString("N"); _cache.Set($"captcha:{guid}", answer, TimeSpan.FromSeconds(challengeTtl)); var base64 = RenderCaptchaImage(questionText, imageWidth, imageHeight); return new BuiltInCaptchaChallenge { Guid = guid, Base64Image = base64 }; } public string? VerifyAnswer(string guid, string answer) { var cacheKey = $"captcha:{guid}"; if (!_cache.TryGetValue(cacheKey, out int expected)) { _logger.LogWarning("Captcha challenge {Guid} not found or expired", guid); return null; } // Remove immediately — one-time use _cache.Remove(cacheKey); if (!int.TryParse(answer.Trim(), out var provided) || provided != expected) { _logger.LogWarning("Captcha answer mismatch for {Guid}: expected {Expected}, got {Answer}", guid, expected, answer); return null; } return _tokenService.IssueToken(); } private static string RenderCaptchaImage(string text, int imageWidth, int imageHeight) { var font = ResolveFont(Random.Shared.Next(14, 28)); var rng = Random.Shared; using var image = new Image(imageWidth, imageHeight); image.Mutate(ctx => { // Background ctx.Fill(Color.White); // Noise dots for (int i = 0; i < 200; i++) { var x = rng.Next(imageWidth); var y = rng.Next(imageHeight); var color = new Color(new Rgba32( (byte)rng.Next(150, 230), (byte)rng.Next(150, 230), (byte)rng.Next(150, 230))); ctx.Fill(color, new SixLabors.ImageSharp.Drawing.EllipsePolygon(x, y, 2)); } // Noise lines for (int i = 0; i < 5; i++) { var lineColor = new Color(new Rgba32( (byte)rng.Next(100, 200), (byte)rng.Next(100, 200), (byte)rng.Next(100, 200))); ctx.DrawLine(lineColor, 1.5f, new PointF(rng.Next(imageWidth), rng.Next(imageHeight)), new PointF(rng.Next(imageWidth), rng.Next(imageHeight))); } // Text var textColor = new Color(new Rgba32( (byte)rng.Next(0, 80), (byte)rng.Next(0, 80), (byte)rng.Next(0, 80))); var options = new RichTextOptions(font) { Origin = new PointF(20, (imageHeight - 28) / 2f) }; ctx.DrawText(options, text, textColor); }); using var ms = new MemoryStream(); image.Save(ms, new PngEncoder()); return Convert.ToBase64String(ms.ToArray()); } private static Font ResolveFont(float size) { if (SystemFonts.TryGet("Arial", out var family)) return family.CreateFont(size, FontStyle.Bold); if (SystemFonts.TryGet("DejaVu Sans", out family)) return family.CreateFont(size, FontStyle.Bold); if (SystemFonts.TryGet("Liberation Sans", out family)) return family.CreateFont(size, FontStyle.Bold); var families = SystemFonts.Families; if (families.Any()) return families.First().CreateFont(size, FontStyle.Bold); throw new InvalidOperationException("No system fonts available for captcha rendering."); } } }