Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion EssentialCSharp.Web/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
using System.Security.Claims;
using System.Text.Json;
using EssentialCSharp.Chat.Common.Services;
using EssentialCSharp.Web.Models;
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;

namespace EssentialCSharp.Web.Controllers;

Expand All @@ -18,15 +20,49 @@ public partial class ChatController : ControllerBase
{
private readonly AIChatService _AIChatService;
private readonly ResponseIdValidationService _ResponseIdValidationService;
private readonly ICaptchaService _CaptchaService;
private readonly CaptchaOptions _CaptchaOptions;
private readonly ILogger<ChatController> _Logger;

public ChatController(ILogger<ChatController> logger, AIChatService aiChatService, ResponseIdValidationService responseIdValidationService)
public ChatController(ILogger<ChatController> logger, AIChatService aiChatService,
ResponseIdValidationService responseIdValidationService,
ICaptchaService captchaService, IOptions<CaptchaOptions> captchaOptions)
{
_AIChatService = aiChatService;
_ResponseIdValidationService = responseIdValidationService;
_CaptchaService = captchaService;
_CaptchaOptions = captchaOptions.Value;
_Logger = logger;
}

/// <summary>
/// Validates the hCaptcha token when captcha is configured.
/// Returns <c>true</c> when captcha is not configured (dev mode) or when the token is valid.
/// Returns <c>false</c> for missing or invalid tokens.
/// Returns <c>null</c> when hCaptcha cannot be reached, so the caller can fail closed.
/// </summary>
private async Task<bool?> IsCaptchaValidAsync(string? token, string? remoteIp, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_CaptchaOptions.SecretKey))
return true; // captcha not configured — skip validation

if (string.IsNullOrWhiteSpace(token))
return false; // token required when captcha is configured — reject without an outbound call

HCaptchaResult? result = await _CaptchaService.VerifyAsync(token, remoteIp, ct);
if (result is null)
{
LogCaptchaServiceUnavailable(_Logger); // hCaptcha unreachable — fail closed
return null;
Comment thread
BenjaminMichaelis marked this conversation as resolved.
}

if (!result.Success)
{
LogCaptchaValidationFailed(_Logger, string.Join(',', result.ErrorCodes ?? []));
}
return result.Success;
}

[HttpPost("message")]
public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest request, CancellationToken cancellationToken = default)
{
Expand All @@ -38,6 +74,12 @@ public async Task<IActionResult> SendMessage([FromBody] ChatMessageRequest reque
if (string.IsNullOrEmpty(userId))
return Unauthorized();

bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken);
if (captchaValid is null)
return StatusCode(503, new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" });
if (!captchaValid.Value)
return StatusCode(403, new { error = "Human verification required.", errorCode = "captcha_failed" });

var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId)
? null
: request.PreviousResponseId.Trim();
Expand Down Expand Up @@ -88,6 +130,20 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
return;
}

bool? captchaValid = await IsCaptchaValidAsync(request.CaptchaResponse, HttpContext.Connection.RemoteIpAddress?.ToString(), cancellationToken);
if (captchaValid is null)
{
Response.StatusCode = 503;
await Response.WriteAsJsonAsync(new { error = "Human verification is temporarily unavailable. Please try again later.", errorCode = "captcha_unavailable" }, cancellationToken);
return;
}
if (!captchaValid.Value)
{
Response.StatusCode = 403;
await Response.WriteAsJsonAsync(new { error = "Human verification required.", errorCode = "captcha_failed" }, cancellationToken);
return;
}

var previousResponseId = string.IsNullOrWhiteSpace(request.PreviousResponseId)
? null
: request.PreviousResponseId.Trim();
Expand Down Expand Up @@ -234,6 +290,12 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
}
}

[LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha service unavailable during chat request — failing closed (503)")]
private static partial void LogCaptchaServiceUnavailable(ILogger<ChatController> logger);

[LoggerMessage(Level = LogLevel.Warning, Message = "hCaptcha validation failed for chat request — error codes: {ErrorCodes}")]
private static partial void LogCaptchaValidationFailed(ILogger<ChatController> logger, string errorCodes);

[LoggerMessage(Level = LogLevel.Debug, Message = "Chat stream cancelled for user {User}")]
private static partial void LogChatStreamCancelled(ILogger<ChatController> logger, string? user);

Expand Down
7 changes: 6 additions & 1 deletion EssentialCSharp.Web/Controllers/ChatMessageRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,10 @@ public class ChatMessageRequest
[StringLength(200)]
public string? PreviousResponseId { get; set; }
public bool EnableContextualSearch { get; set; } = true;
public string? CaptchaResponse { get; set; } // For future captcha implementation
/// <summary>
/// hCaptcha token obtained from the client-side invisible widget.
/// Required when <c>CaptchaOptions.SecretKey</c> is configured; ignored otherwise.
/// </summary>
[StringLength(2000)]
public string? CaptchaResponse { get; set; }
Comment thread
BenjaminMichaelis marked this conversation as resolved.
}
17 changes: 2 additions & 15 deletions EssentialCSharp.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,6 @@ private static void Main(string[] args)
c.Timeout = TimeSpan.FromSeconds(3);
});



builder.Services.AddTrustedForwardedHeaders(builder.Configuration, builder.Environment);

ConfigurationManager configuration = builder.Configuration;
Expand Down Expand Up @@ -312,7 +310,7 @@ private static void Main(string[] args)
return RateLimitPartition.GetNoLimiter("mcp-transport");

var partitionKey = httpContext.User.Identity?.IsAuthenticated == true
? httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "unknown-user"
? httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"
: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";

return RateLimitPartition.GetFixedWindowLimiter(
Expand All @@ -330,7 +328,7 @@ private static void Main(string[] args)
{
// Partitioned per-user (when authenticated) or per-IP (anonymous)
var partitionKey = httpContext.User.Identity?.IsAuthenticated == true
? $"chat-user:{httpContext.User.Identity.Name ?? "unknown-user"}"
? $"chat-user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}"
: $"chat-ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}";

return RateLimitPartition.GetFixedWindowLimiter(
Expand Down Expand Up @@ -367,7 +365,6 @@ private static void Main(string[] args)
Dictionary<string, object> errorResponse = new()
{
["error"] = "Rate limit exceeded. Please wait before sending another message.",
["requiresCaptcha"] = true,
["statusCode"] = 429
Comment thread
BenjaminMichaelis marked this conversation as resolved.
};
if (retryAfterSeconds is int retryAfter)
Expand Down Expand Up @@ -619,16 +616,6 @@ await McpJsonRpcResponseWriter.WriteErrorAsync(
LogSitemapValidationFailed(logger, ex);
// Continue startup even if sitemap validation fails
}
catch (ArgumentException ex)
{
LogSitemapValidationFailed(logger, ex);
// Continue startup even if sitemap validation fails
}
catch (FormatException ex)
{
LogSitemapValidationFailed(logger, ex);
// Continue startup even if sitemap validation fails
}

app.Run();
}
Expand Down
2 changes: 1 addition & 1 deletion EssentialCSharp.Web/Services/ContentRateLimiterPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public RateLimitPartition<string> GetPartition(HttpContext httpContext)
// Use stable user ID (GUID) for authenticated users so the bucket survives
// username changes and doesn't conflate login/logout with scraping.
string partitionKey = isAuthenticated
? $"user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? httpContext.User.Identity!.Name ?? "unknown"}"
? $"user:{httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown-user"}"
: $"ip:{httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"}";

int perMinuteLimit = isAuthenticated ? AuthenticatedPerMinute : AnonymousPerMinute;
Expand Down
1 change: 0 additions & 1 deletion EssentialCSharp.Web/Services/McpRateLimiterPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ public RateLimitPartition<string> GetPartition(HttpContext httpContext)
if (httpContext.User.Identity?.IsAuthenticated == true)
{
string userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? httpContext.User.Identity?.Name
?? "unknown-user";

return RateLimitPartition.GetTokenBucketLimiter(
Expand Down
7 changes: 7 additions & 0 deletions EssentialCSharp.Web/Views/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
@using Microsoft.AspNetCore.Identity
@using EssentialCSharp.Web.Areas.Identity.Data
@using Microsoft.Extensions.Configuration
@using Microsoft.Extensions.Options
@inject ISiteMappingService _SiteMappings
@inject SignInManager<EssentialCSharpWebUser> SignInManager
@inject IConfiguration Configuration
@inject IOptions<CaptchaOptions> _CaptchaOptions
<!DOCTYPE html>
<html lang="en">
<head>
Expand Down Expand Up @@ -182,6 +184,10 @@
var buildLabel = ReleaseDateAttribute.GetReleaseDate() is DateTime date
? TimeZoneInfo.ConvertTimeFromUtc(date, TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time")).ToString("d MMM, yyyy h:mm:ss tt", CultureInfo.InvariantCulture)
: null;
var chatCaptchaSiteKey = !string.IsNullOrWhiteSpace(_CaptchaOptions.Value.SecretKey)
&& !string.IsNullOrWhiteSpace(_CaptchaOptions.Value.SiteKey)
? _CaptchaOptions.Value.SiteKey
: null;
}
window.PERCENT_COMPLETE = @Json.Serialize(percentComplete);
window.PREVIOUS_PAGE = @Json.Serialize(ViewBag.PreviousPage);
Expand All @@ -192,6 +198,7 @@
window.TRYDOTNET_ORIGIN = @Json.Serialize(Configuration["TryDotNet:Origin"]);
window.BUILD_LABEL = @Json.Serialize(buildLabel);
window.ENABLE_CHAT_WIDGET = @Json.Serialize(!Context.Request.Path.StartsWithSegments("/Identity"));
window.HCAPTCHA_SITE_KEY = @Json.Serialize(chatCaptchaSiteKey);
</script>
<script src="~/dist/assets/site-shell.js" type="module" asp-append-version="true"></script>
</body>
Expand Down
20 changes: 20 additions & 0 deletions EssentialCSharp.Web/src/components/ChatWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
isTyping,
chatMessagesEl,
chatInputField,
captchaSiteKey,
openChatDialog,
closeChatDialog,
clearChatHistory,
Expand All @@ -23,6 +24,18 @@ const {

<template>
<div class="chat-widget">
<!--
Invisible hCaptcha container: lives outside the v-if dialog so the widget
persists across open/close cycles and only needs to be initialized once.
Renders only when captcha is configured (HCAPTCHA_SITE_KEY is non-null).
-->
<div
v-if="captchaSiteKey"
id="chat-captcha-container"
class="visually-hidden"
aria-hidden="true"
/>

<button
class="chat-button elevation-6"
:class="{ 'chat-button--active': showChatDialog }"
Expand Down Expand Up @@ -189,6 +202,13 @@ const {
Type your question and press Enter or click send. Maximum 500 characters.
</div>
</form>
<!-- hCaptcha legal disclosure required for invisible mode -->
<p v-if="captchaSiteKey" class="captcha-notice small text-muted mt-1">
Protected by hCaptcha —
<a href="https://www.hcaptcha.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a>
&amp;
<a href="https://www.hcaptcha.com/terms" target="_blank" rel="noopener noreferrer">Terms</a>
</p>
</div>
</div>
</div>
Expand Down
Loading
Loading