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
8 changes: 8 additions & 0 deletions Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ public class KeywordIntentClassifier : INLUProvider
"list_messages", null),
(R(@"^(show|list|get|what'?s)\s+(on\s+)?(the\s+)?(calendar|schedule|agenda)"),
"list_calendar", null),
// Upcoming-calendar phrasings: "when is the next event?", "what is upcoming in the
// calendar?", "upcoming events", "what's coming up", "next events".
(R(@"^when('?s|\s+is)\s+(the\s+)?next\s+(event|meeting|training|class)(s|es)?$"),
"list_calendar", null),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Hardcoded string literal "list_calendar" represents a finite intent set prone to typos and poor discoverability. Define an enum (e.g., IntentTypes.ListCalendar) or a constants class and reference it in place of the literal.

Kody rule violation: Use enums instead of magic strings

Prompt for LLM

File Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs:

Line 179:

Hardcoded string literal `"list_calendar"` represents a finite intent set prone to typos and poor discoverability. Define an enum (e.g., `IntentTypes.ListCalendar`) or a constants class and reference it in place of the literal.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Shared string literal "list_calendar" repeats across multiple entries in KeywordIntentClassifier.cs:181 and KeywordIntentClassifier.cs:183, risking inconsistency. Define a constant (e.g., const string ListCalendarIntent = "list_calendar") in a shared intents class and reference it here.

Kody rule violation: Centralize string constants

Prompt for LLM

File Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs:

Line 179:

Shared string literal `"list_calendar"` repeats across multiple entries in `KeywordIntentClassifier.cs:181` and `KeywordIntentClassifier.cs:183`, risking inconsistency. Define a constant (e.g., `const string ListCalendarIntent = "list_calendar"`) in a shared intents class and reference it here.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

(R(@"^(what('?s|\s+is)\s+)?(upcoming|coming\s+up)(\s+(events?|meetings?|trainings?))?(\s+(on|in)\s+(the\s+)?(calendar|schedule|agenda))?$"),
"list_calendar", null),
(R(@"^(next|upcoming)\s+(events?|meetings?|trainings?)$"),
"list_calendar", null),
(R(@"^(show|list|get|my)\s+shifts?"),
"list_shifts", null),
(R(@"^(weather\s+)?(alerts?|warnings?)"),
Expand Down
13 changes: 13 additions & 0 deletions Core/Resgrid.Services/ChatMessageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,19 @@ public async Task<List<ChatMessage>> GetThreadPageAsync(string threadRootMessage
if (member != null && (member.IsBanned || (member.MutedUntil.HasValue && member.MutedUntil.Value > DateTime.UtcNow)))
return false;

// Double-taps are common: bail out before the insert so the ordinary duplicate never
// reaches the database (RepositoryBase logs every insert exception, so relying on the
// unique-violation catch below alone floods the error log). The catch still covers the
// genuine concurrent race two requests can win simultaneously.
var existingReactions = await _chatMessageReactionRepository.GetByMessageIdsAsync(new[] { chatMessageId });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unguarded await: GetByMessageIdsAsync sits before the try block at line 359, so transient failures (timeouts, deadlocks) propagate unhandled. Move the read inside the try block or wrap it in its own try/catch that logs context (messageId, emoji) and returns a safe default.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File Core/Resgrid.Services/ChatMessageService.cs:

Line 350:

Unguarded await: `GetByMessageIdsAsync` sits before the try block at line 359, so transient failures (timeouts, deadlocks) propagate unhandled. Move the read inside the try block or wrap it in its own try/catch that logs context (`messageId`, `emoji`) and returns a safe default.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unguarded external DB call: GetByMessageIdsAsync is not wrapped in a try/catch with context, violating the requirement that network/DB/external calls include structured context and map errors to application-level errors. Wrap the repository read in try/catch, log with structured context (chatMessageId, emoji, unitId, userId), and either fall through to the insert path or return a deterministic result on failure.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File Core/Resgrid.Services/ChatMessageService.cs:

Line 350:

Unguarded external DB call: `GetByMessageIdsAsync` is not wrapped in a try/catch with context, violating the requirement that network/DB/external calls include structured context and map errors to application-level errors. Wrap the repository read in try/catch, log with structured context (`chatMessageId`, `emoji`, `unitId`, `userId`), and either fall through to the insert path or return a deterministic result on failure.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

var alreadyReacted = existingReactions != null && existingReactions.Any(r =>
string.Equals(r.Emoji, emoji, StringComparison.Ordinal)
&& (unitId.HasValue
? r.UnitId == unitId
: r.UserId != null && string.Equals(r.UserId, userId, StringComparison.OrdinalIgnoreCase)));
if (alreadyReacted)
return true;

try
{
await _chatMessageReactionRepository.InsertAsync(new ChatMessageReaction
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using FluentMigrator;

namespace Resgrid.Providers.Migrations.Migrations
{
/// <summary>
/// The initial schema created Notes and Documents with a misspelled "Catery" column while the
/// entities (and the Dapper-generated INSERT/UPDATE statements) use "Category" — every save
/// against a database built from M0001 failed with "invalid column name 'Category'". Renames
/// the column where the typo exists; guarded so databases that already have the correct
/// column (or were hand-fixed) are untouched.
/// </summary>
[Migration(113)]
public class M0113_FixNotesDocumentsCategoryColumn : Migration
{
public override void Up()
{
Execute.Sql(@"
IF COL_LENGTH('dbo.Notes', 'Catery') IS NOT NULL AND COL_LENGTH('dbo.Notes', 'Category') IS NULL
EXEC sp_rename 'dbo.Notes.Catery', 'Category', 'COLUMN';");

Execute.Sql(@"
IF COL_LENGTH('dbo.Documents', 'Catery') IS NOT NULL AND COL_LENGTH('dbo.Documents', 'Category') IS NULL
EXEC sp_rename 'dbo.Documents.Catery', 'Category', 'COLUMN';");
}

public override void Down()
{
// One-way typo fix; nothing to restore.
}
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using FluentMigrator;

namespace Resgrid.Providers.MigrationsPg.Migrations
{
/// <summary>
/// The initial schema created notes and documents with a misspelled "catery" column while the
/// entities (and the Dapper-generated INSERT/UPDATE statements) use "category" — every save
/// against a database built from M0001 failed with 42703 "column category does not exist".
/// Renames the column where the typo exists; guarded so databases that already have the
/// correct column (or were hand-fixed) are untouched.
/// </summary>
[Migration(113)]
public class M0113_FixNotesDocumentsCategoryColumnPg : Migration
{
public override void Up()
{
Execute.Sql(@"
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'notes' AND column_name = 'catery')
AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'notes' AND column_name = 'category') THEN
ALTER TABLE public.notes RENAME COLUMN catery TO category;
END IF;

IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'documents' AND column_name = 'catery')
AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'documents' AND column_name = 'category') THEN
ALTER TABLE public.documents RENAME COLUMN catery TO category;
END IF;
END $$;");
}

public override void Down()
{
// One-way typo fix; nothing to restore.
}
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ public void Setup()
[TestCase("Whats my schedule?", "my_schedule")]
[TestCase("my unread messages?", "list_messages")]
[TestCase("new messages", "list_messages")]
[TestCase("When is the next event?", "list_calendar")]
[TestCase("when's the next meeting", "list_calendar")]
[TestCase("What is upcoming in the calendar?", "list_calendar")]
[TestCase("what's coming up", "list_calendar")]
[TestCase("upcoming events", "list_calendar")]
[TestCase("next events", "list_calendar")]
[TestCase("Whats my schedule", "my_schedule")]
public async Task Classifies_intent(string text, string expectedIntent)
{
var result = await _classifier.ClassifyAsync(text);
Expand Down
61 changes: 61 additions & 0 deletions Tests/Resgrid.Tests/Services/ChatMessageServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,66 @@ public async Task DeleteMessageAsync_should_use_one_effective_actor_classificati
payload.Value<bool>("DeletedByModerator").Should().Be(expectedModerated);
payload.Value<bool>("IsModerated").Should().Be(expectedModerated);
}

// Double-tapping a reaction fires two AddReaction calls; the second must no-op without
// attempting the insert (the unique-index violation would flood the error log).
[TestCase("🙏", true, false)] // same emoji already present -> success, no insert
[TestCase("🔥", true, true)] // different emoji -> insert proceeds
public async Task AddReactionAsync_is_idempotent_for_duplicate_reactions(string emoji, bool expectedResult, bool expectInsert)
{
var message = new ChatMessage
{
ChatMessageId = "message-1",
ChatChannelId = "channel-1",
DepartmentId = 1,
SenderUserId = "sender",
Body = "body"
};
var channel = new ChatChannel { ChatChannelId = message.ChatChannelId, DepartmentId = message.DepartmentId };
var channelRepository = new Mock<IChatChannelRepository>();
var messageRepository = new Mock<IChatMessageRepository>();
var reactionRepository = new Mock<IChatMessageReactionRepository>();

messageRepository.Setup(x => x.GetByIdAsync(message.ChatMessageId)).ReturnsAsync(message);
channelRepository.Setup(x => x.GetByIdAsync(channel.ChatChannelId)).ReturnsAsync(channel);
reactionRepository
.Setup(x => x.GetByMessageIdsAsync(It.IsAny<System.Collections.Generic.IEnumerable<string>>()))
.ReturnsAsync(new[]
{
new ChatMessageReaction
{
ChatMessageId = message.ChatMessageId,
ParticipantType = (int)ChatParticipantType.User,
UserId = "USER-1",
Emoji = "🙏"
}
});
reactionRepository
.Setup(x => x.InsertAsync(It.IsAny<ChatMessageReaction>(), It.IsAny<CancellationToken>(), false))
.ReturnsAsync((ChatMessageReaction reaction, CancellationToken _, bool __) => reaction);

var service = new ChatMessageService(
channelRepository.Object,
messageRepository.Object,
Mock.Of<IChatMessageEditRepository>(),
Mock.Of<IChatAttachmentRepository>(),
reactionRepository.Object,
Mock.Of<IChatMessageMentionRepository>(),
Mock.Of<IChatMessageAckRepository>(),
Mock.Of<IChatChannelMemberRepository>(),
Mock.Of<IChatChannelService>(),
Mock.Of<IChatPermissionService>(),
Mock.Of<IUserProfileService>(),
Mock.Of<IUnitsService>(),
Mock.Of<IEventAggregator>());

// Case-insensitive user match: stored UserId is "USER-1", caller sends "user-1".
var result = await service.AddReactionAsync(message.ChatMessageId, "user-1", null, emoji);

result.Should().Be(expectedResult);
reactionRepository.Verify(
x => x.InsertAsync(It.IsAny<ChatMessageReaction>(), It.IsAny<CancellationToken>(), false),
expectInsert ? Times.Once() : Times.Never());
}
}
}
5 changes: 4 additions & 1 deletion Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,9 @@ public async Task<ActionResult<GetChatMessageResult>> EditMessage(string message
if (input == null || String.IsNullOrWhiteSpace(input.Body))
return BadRequest();

if (await IsChatbotMessageChannelAsync(messageId))
return BadRequest("Messages can't be edited in assistant conversations.");

var message = await _chatMessageService.EditMessageAsync(messageId, UserId, input.Body, cancellationToken);

if (message == null)
Expand Down Expand Up @@ -1589,7 +1592,7 @@ private async Task<bool> IsRateLimitedAsync(string action, int limitPerWindow)

/// <summary>
/// True when the message lives in an assistant (chatbot) conversation, where reactions,
/// threads and deletes are not available.
/// threads, deletes and edits are not available.
/// </summary>
private async Task<bool> IsChatbotMessageChannelAsync(string messageId)
{
Expand Down
2 changes: 1 addition & 1 deletion Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@
<member name="M:Resgrid.Web.Services.Controllers.v4.ChatController.IsChatbotMessageChannelAsync(System.String)">
<summary>
True when the message lives in an assistant (chatbot) conversation, where reactions,
threads and deletes are not available.
threads, deletes and edits are not available.
</summary>
</member>
<member name="M:Resgrid.Web.Services.Controllers.v4.ChatController.CheckMessageChannelAccessAsync(System.String)">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import './chat.css';
import { getCurrentUserId, type ChatChannelDto, type ChatMessageDto } from './types';
import { ChatChannelType, getCurrentUserId, type ChatChannelDto, type ChatMessageDto } from './types';
import { useChatBootstrap } from './useChatBootstrap';
import { useChatStore, shallowArrayEqual } from './useChatStore';
import { setActiveChannel } from './chatStore';
Expand All @@ -21,8 +21,11 @@ export interface ChatPanelElementProps {

export default function ChatPanelElement({ hostElement, label = 'Chat' }: ChatPanelElementProps) {
const { available, loaded, loadFailed, reload, connect } = useChatBootstrap();
const channels = useChatStore((state) => state.channels, shallowArrayEqual);
const unread = useChatStore((state) => state.channels.reduce((total, channel) => total + Math.max(0, channel.UnreadCount), 0));
const allChannels = useChatStore((state) => state.channels, shallowArrayEqual);
// The assistant has its own footer button/drawer (rg-assistant); keep its channel — and its
// unread count — out of the chat popout entirely.
const channels = allChannels.filter((channel) => channel.ChannelType !== ChatChannelType.Chatbot);
const unread = channels.reduce((total, channel) => total + Math.max(0, channel.UnreadCount), 0);

const [open, setOpen] = useState(false);
const [activeChannelId, setActiveChannelId] = useState<string | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ export default function ConversationView(props: ConversationViewProps) {
const channelId = channel.ChatChannelId;
// Assistant conversations are restricted regardless of where they're opened (footer drawer uses
// variant='bot'; the chat page renders the same channel with the default variant): text only —
// no emoji picker, GIFs, images, urgent priority, reactions, threads or deletes. Pin, flag and
// editing your own messages stay available.
// no emoji picker, GIFs, images, urgent priority, reactions, threads, deletes or edits. Pin and
// flag stay available.
const isBot = variant === 'bot' || channel.ChannelType === ChatChannelType.Chatbot;

const allMessages = useChatStore((state) => state.messagesByChannel[channelId] ?? EMPTY_MESSAGES, shallowArrayEqual);
Expand Down Expand Up @@ -313,7 +313,7 @@ export default function ConversationView(props: ConversationViewProps) {
showAckStatus={message.Priority === 1 && (message.SenderUserId === currentUserId || !!canModerate)}
onReact={isBot ? undefined : handleReact}
onOpenThread={isBot ? undefined : props.onOpenThread}
onSaveEdit={handleSaveEdit}
onSaveEdit={isBot ? undefined : handleSaveEdit}
onDelete={isBot ? undefined : handleDelete}
onPin={canModerate ? handlePin : undefined}
onFlag={props.onFlag}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,16 @@ export default function Composer({
</label>
)}

<input ref={fileRef} type="file" accept="image/*" className="rgchat-fileinput" onChange={(event) => void handleFile(event.target.files?.[0])} />
{allowImages && (
<input
ref={fileRef}
type="file"
accept="image/*"
className="rgchat-fileinput"
style={{ display: 'none' }}
onChange={(event) => void handleFile(event.target.files?.[0])}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Inline arrow function in the onChange JSX prop creates a new function on every render, impacting performance. Move the function definition outside the render method.

Kody rule violation: Avoid using .bind() or arrow functions in JSX props

Prompt for LLM

File Web/Resgrid.Web/Areas/User/Apps/src/components/chat/atoms/Composer.tsx:

Line 322:

Inline arrow function in the `onChange` JSX prop creates a new function on every render, impacting performance. Move the function definition outside the render method.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

/>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function MessageBubble(props: MessageBubbleProps) {
bubbleClasses.push('rgchat-bubble--bot');
}

const canEdit = isMine && message.MessageType === ChatMessageType.Text && !isDeleted && !isFailed;
const canEdit = isMine && message.MessageType === ChatMessageType.Text && !isDeleted && !isFailed && !!props.onSaveEdit;
const canDelete = (isMine || canModerate) && !isDeleted && !isFailed;

const renderContent = () => {
Expand Down
8 changes: 6 additions & 2 deletions Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -999,10 +999,14 @@ rg-chat {
animation: rgchat-pop-in 140ms ease;
}

/* Narrow hosts (footer chat popout / assistant drawer): the quick-reactions row would extend
past the panel edge and get clipped, so wrap it into a compact grid instead. */
/* Narrow hosts (footer chat popout / assistant drawer): anchored to the right edge of the
actions row, the quick-reactions popover extends left past the panel edge and gets clipped.
Anchor it to the LEFT edge instead so it grows rightward over the message area, and wrap it
into a compact grid as a guard for very narrow widths. */
.rgchat-panel .rgchat-popover--reactions,
.rgchat-drawer .rgchat-popover--reactions {
left: 0;
right: auto;
flex-wrap: wrap;
justify-content: center;
width: max-content;
Expand Down
51 changes: 37 additions & 14 deletions Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Linq;
using Resgrid.Config;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;

namespace Resgrid.WebCore.Attributes
{
public class GoogleReCaptchaValidationAttribute : ValidationAttribute
{
// Shared client: a new HttpClient per validation leaks sockets under load ("Resource
// temporarily unavailable" on the register form). Validation attributes are synchronous,
// so the call is bounded by a short timeout instead of the 100-second default.
private static readonly HttpClient _httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(10)
};

protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
Expand All @@ -22,23 +31,37 @@ protected override ValidationResult IsValid(object value, ValidationContext vali
String reCaptchResponse = value.ToString();
String reCaptchaSecret = WebConfig.RecaptchaPrivateKey;


HttpClient httpClient = new HttpClient();
var httpResponse = httpClient.GetAsync($"https://www.google.com/recaptcha/api/siteverify?secret={reCaptchaSecret}&response={reCaptchResponse}").Result;
if (httpResponse.StatusCode != HttpStatusCode.OK)
try
{
return errorResult.Value;
}
// POST keeps the secret out of URLs (request logs, proxies).
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["secret"] = reCaptchaSecret,
["response"] = reCaptchResponse
});

String jsonResponse = httpResponse.Content.ReadAsStringAsync().Result;
dynamic jsonData = JObject.Parse(jsonResponse);
if (jsonData.success != true.ToString().ToLower())
{
return errorResult.Value;
}
var httpResponse = _httpClient.PostAsync("https://www.google.com/recaptcha/api/siteverify", content).GetAwaiter().GetResult();
if (httpResponse.StatusCode != HttpStatusCode.OK)
{
return errorResult.Value;
}

return ValidationResult.Success;
String jsonResponse = httpResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult();
Comment on lines +43 to +49

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate existing async request-validation components and all reCAPTCHA uses.
rg -n -C 3 --glob '*.cs' \
  'GoogleReCaptchaValidationAttribute|IAsync(Action|Authorization|Resource)Filter|ModelState\.AddModelError' \
  Web

Repository: Resgrid/Core

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the reCAPTCHA attribute and focused usages without dumping the entire Web/Controllers match list.
printf -- '--- GoogleReCaptchaValidationAttribute.cs ---\n'
cat -n Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs

printf -- '\n--- GoogleReCaptchaValidationAttribute references ---\n'
rg -n --glob '*.cshtml' --glob '*.cs' --glob '*.razor' 'GoogleReCaptchaValidation|g-recaptcha|recaptcha' Web/Resgrid.Web

printf -- '\n--- AccountController POST snippets around register actions ---\n'
rg -n -A 80 -B 10 'Register|RegisterModel|GoogleReCaptcha|g-recaptcha' Web/Resgrid.Web/Controllers/AccountController.cs

Repository: Resgrid/Core

Length of output: 10069


Move reCAPTCHA verification out of synchronous model validation.

IsValid() blocks the request thread for both the POST and response-body read, up to the 10-second timeout. Replace the synchronous ValidationAttribute path with an async filter or equivalent request component so concurrent validation calls do not block request threads, and add failures to ModelState.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs` around
lines 43 - 49, Replace the synchronous verification in
GoogleReCaptchaValidationAttribute.IsValid with an asynchronous action filter or
equivalent request component that awaits both the reCAPTCHA POST and
response-body read without blocking request threads. Preserve the existing
validation outcome for non-OK responses and verification failures, and add those
failures to ModelState before the action executes.

dynamic jsonData = JObject.Parse(jsonResponse);
Comment on lines +49 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Confirm the installed Newtonsoft.Json reference and inspect parsing exception handling.
rg -n -C 3 --glob '*.csproj' --glob 'packages.config' --glob '*.cs' \
  'Newtonsoft\.Json|JObject\.Parse|JsonException|JsonReaderException' \
  Web

Repository: Resgrid/Core

Length of output: 43035


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the validation attribute around the parsing and exception-handling logic.
sed -n '1,140p' Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs | cat -n

# Search for the exact method/attribute to ensure context is complete.
rg -n "class GoogleReCaptchaValidationAttribute|ValidationAttribute|LogException|TryVerify|OnAuthorization" Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs

Repository: Resgrid/Core

Length of output: 3195


🌐 Web query:

Newtonsoft.Json 13.0.3 JObject.Parse JsonException documentation

💡 Result:

In Newtonsoft.Json version 13.0.3, the JObject.Parse method is designed to parse a JSON string into a JObject [1][2]. If the provided string is not valid JSON or does not represent a JSON object (e.g., if it represents a JSON array or a primitive), the method throws a JsonReaderException [1][3][4]. JsonReaderException is a subclass of JsonException [5][6]. This exception typically includes detailed information about the error, such as the path in the JSON string where the error occurred, the line number, and the position [5]. Common scenarios that trigger this exception include: - Passing invalid JSON syntax [1][4]. - Passing a root JSON element that is not an object, such as a JSON array ("[]") [3]. In such cases, if you expect an array, JArray.Parse should be used instead; if the JSON structure is unknown, JToken.Parse is the appropriate method [3]. When handling parsing errors, it is standard practice to catch JsonReaderException specifically to handle malformed input, rather than catching the base JsonException or the generic System.Exception, which may obscure other issues [7][5][6].

Citations:


Fail closed when Google returns a non-object reCAPTCHA response.

JObject.Parse(jsonResponse) throws for malformed JSON and non-object status-200 payloads, but the filter at line 58 only catches transport failures. Catch the JSON parsing exception, log it with Framework.Logging.LogException, and return the same retryable validation result instead of 500-ing the page.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs` around
lines 49 - 50, Update the JSON handling in GoogleReCaptchaValidationAttribute so
JObject.Parse failures for malformed or non-object responses are caught
separately from transport errors. Log the parsing exception through
Framework.Logging.LogException and return the existing retryable validation
result, preserving the current behavior for valid object responses.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Untrusted JSON: JObject.Parse(Response) parses Google's reCAPTCHA endpoint response without validating its shape, and the surrounding catch filter (HttpRequestException/TaskCanceledException/OperationCanceledException) will not catch a JsonReaderException. Parse inside a try/catch that also handles Newtonsoft.Json.JsonException, then assert required fields (e.g., Data.success is JToken) before using them, returning the retryable ValidationResult on malformed input.

Kody rule violation: Always validate JSON parsing

Prompt for LLM

File Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs:

Line 50:

Untrusted JSON: `JObject.Parse(Response)` parses Google's reCAPTCHA endpoint response without validating its shape, and the surrounding catch filter (`HttpRequestException`/`TaskCanceledException`/`OperationCanceledException`) will not catch a `JsonReaderException`. Parse inside a try/catch that also handles `Newtonsoft.Json.JsonException`, then assert required fields (e.g., `Data.success` is `JToken`) before using them, returning the retryable `ValidationResult` on malformed input.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

if (jsonData.success != true.ToString().ToLower())
{
return errorResult.Value;
}

return ValidationResult.Success;
}
catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is OperationCanceledException)
{
// Transient network/DNS failure reaching Google: fail closed with a retryable
// validation message instead of letting the exception 500 the register page.
Framework.Logging.LogException(ex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Missing structured context: the error log only passes the exception object with no operation name or relevant identifiers. Pass structured context, e.g., Framework.Logging.LogException(ex, new { op = "recaptcha.siteverify", member = validationContext.MemberName }), or use an overload that accepts a message and identifiers.

Kody rule violation: Include error context in structured logs

Prompt for LLM

File Web/Resgrid.Web/Attributes/GoogleReCaptchaValidationAttribute.cs:

Line 62:

Missing structured context: the error log only passes the exception object with no operation name or relevant identifiers. Pass structured context, e.g., `Framework.Logging.LogException(ex, new { op = "recaptcha.siteverify", member = validationContext.MemberName })`, or use an overload that accepts a message and identifiers.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

return new ValidationResult("We couldn't verify the reCAPTCHA right now. Please try again.", new String[] { validationContext.MemberName });
}
}
}
}
Loading
Loading