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
90 changes: 32 additions & 58 deletions Core/Resgrid.Services/WeatherAlertService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ public async Task ProcessWeatherAlertSourceAsync(Guid sourceId, CancellationToke
var existing = await _weatherAlertRepository.GetByExternalIdAndSourceIdAsync(
alert.ExternalId, source.WeatherAlertSourceId);

TruncateAlertFields(alert);

if (existing == null)
{
// New alert
Expand Down Expand Up @@ -525,78 +527,31 @@ private static string FormatAlertMessageBody(WeatherAlert alert, Department depa
{
var sb = new System.Text.StringBuilder();

// Header
sb.AppendLine($"=== WEATHER ALERT: {alert.Event?.ToUpper()} ===");
sb.AppendLine();
sb.AppendLine($"WEATHER ALERT: {alert.Event?.ToUpper()}");
sb.AppendLine($"Severity: {SeverityNames[Math.Min(alert.Severity, 4)]}");

// Headline
if (!string.IsNullOrEmpty(alert.Headline))
if (alert.ExpiresUtc.HasValue)
{
sb.AppendLine(alert.Headline);
sb.AppendLine();
if (department != null)
sb.AppendLine($"Expires: {alert.ExpiresUtc.Value.TimeConverter(department):MM/dd/yyyy h:mm tt}");
else
sb.AppendLine($"Expires: {alert.ExpiresUtc.Value:yyyy-MM-dd HH:mm} UTC");
}

// Classification grid
sb.AppendLine("--- Alert Details ---");
sb.AppendLine($"Severity: {SeverityNames[Math.Min(alert.Severity, 4)]}");
sb.AppendLine($"Urgency: {UrgencyNames[Math.Min(alert.Urgency, 4)]}");
sb.AppendLine($"Certainty: {CertaintyNames[Math.Min(alert.Certainty, 4)]}");
sb.AppendLine($"Category: {CategoryNames[Math.Min(alert.AlertCategory, 4)]}");
sb.AppendLine();

// Timing
sb.AppendLine("--- Timing ---");
if (department != null)
{
sb.AppendLine($"Effective: {alert.EffectiveUtc.TimeConverter(department):MM/dd/yyyy h:mm tt}");
if (alert.OnsetUtc.HasValue)
sb.AppendLine($"Onset: {alert.OnsetUtc.Value.TimeConverter(department):MM/dd/yyyy h:mm tt}");
if (alert.ExpiresUtc.HasValue)
sb.AppendLine($"Expires: {alert.ExpiresUtc.Value.TimeConverter(department):MM/dd/yyyy h:mm tt}");
}
else
{
sb.AppendLine($"Effective: {alert.EffectiveUtc:yyyy-MM-dd HH:mm} UTC");
if (alert.OnsetUtc.HasValue)
sb.AppendLine($"Onset: {alert.OnsetUtc.Value:yyyy-MM-dd HH:mm} UTC");
if (alert.ExpiresUtc.HasValue)
sb.AppendLine($"Expires: {alert.ExpiresUtc.Value:yyyy-MM-dd HH:mm} UTC");
}
sb.AppendLine();

// Affected area
if (!string.IsNullOrEmpty(alert.AreaDescription))
{
sb.AppendLine("--- Affected Area ---");
sb.AppendLine(alert.AreaDescription);
sb.AppendLine();
}

// Description
if (!string.IsNullOrEmpty(alert.Description))
{
sb.AppendLine("--- Description ---");
sb.AppendLine(alert.Description);
sb.AppendLine();
}
if (!string.IsNullOrEmpty(alert.Headline))
sb.AppendLine(alert.Headline);

// Instructions (critical for responders)
if (!string.IsNullOrEmpty(alert.Instruction))
{
sb.AppendLine("--- INSTRUCTIONS ---");
sb.AppendLine(alert.Instruction);
sb.AppendLine();
sb.AppendLine(alert.Instruction);
}

// Source info
if (!string.IsNullOrEmpty(alert.Sender))
sb.AppendLine($"Source: {alert.Sender}");

sb.AppendLine($"Alert ID: {alert.ExternalId}");
sb.AppendLine();
sb.AppendLine("This is an automated weather alert from the Resgrid Weather Alert System.");
sb.AppendLine("View active weather alerts for full details.");

// Respect the 4000 char body limit
var body = sb.ToString();
if (body.Length > 3950)
body = body.Substring(0, 3947) + "...";
Expand All @@ -619,5 +574,24 @@ private static double CalculateDistanceMiles(double lat1, double lng1, double la
private static double ToRadians(double degrees) => degrees * Math.PI / 180;

#endregion

private static void TruncateAlertFields(WeatherAlert alert)
{
alert.ExternalId = Truncate(alert.ExternalId, 500);
alert.Sender = Truncate(alert.Sender, 500);
alert.Event = Truncate(alert.Event, 500);
alert.Headline = Truncate(alert.Headline, 500);
alert.AreaDescription = Truncate(alert.AreaDescription, 500);
alert.CenterGeoLocation = Truncate(alert.CenterGeoLocation, 100);
alert.ReferencesExternalId = Truncate(alert.ReferencesExternalId, 500);
}

private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
return value;

return value.Substring(0, maxLength);
}
}
}
27 changes: 26 additions & 1 deletion Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ internal class RabbitConnection
private static ConnectionFactory _factory { get; set; }
private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

/// <summary>
/// Raised when the connection is reset so callers can clear cached declaration state.
/// </summary>
public static event Action ConnectionReset;

public static async Task<bool> VerifyAndCreateClients(string clientName)
{
Expand All @@ -21,6 +25,7 @@ public static async Task<bool> VerifyAndCreateClients(string clientName)
_connection.Dispose();
_connection = null;
_factory = null;
RaiseConnectionReset();
}

if (_connection == null)
Expand Down Expand Up @@ -77,7 +82,7 @@ public static async Task<bool> VerifyAndCreateClients(string clientName)
{
try
{
var channel = await _connection.CreateChannelAsync();
using var channel = await _connection.CreateChannelAsync();

await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.SystemQueueName),
durable: true,
Expand Down Expand Up @@ -180,13 +185,33 @@ public static async Task<IConnection> CreateConnection(string clientName)
_connection.Dispose();
_connection = null;
_factory = null;
RaiseConnectionReset();

await VerifyAndCreateClients(clientName);
}

return _connection;
}

private static void RaiseConnectionReset()
{
var handler = ConnectionReset;
if (handler == null)
return;

foreach (var subscriber in handler.GetInvocationList())
{
try
{
((Action)subscriber).Invoke();
}
catch (Exception ex)
{
Logging.LogException(ex);
}
}
}

public static string SetQueueNameForEnv(string cacheKey)
{
if (Config.SystemBehaviorConfig.Environment == SystemEnvironment.Dev)
Expand Down
12 changes: 12 additions & 0 deletions Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ namespace Resgrid.Providers.Bus.Rabbit
public class RabbitTopicProvider
{
private readonly string _clientName = "Resgrid-Topic";
private static volatile bool _exchangeDeclared;

static RabbitTopicProvider()
{
RabbitConnection.ConnectionReset += () => _exchangeDeclared = false;
}

public async Task<bool> PersonnelStatusChanged(UserStatusEvent message)
{
Expand Down Expand Up @@ -117,14 +123,20 @@ private static async Task<bool> VerifyAndCreateClients(string clientName)
{
try
{
// Validate/create connection first so a reconnect clears _exchangeDeclared
var connection = await RabbitConnection.CreateConnection(clientName);

if (_exchangeDeclared)
return true;

if (connection != null)
{
using (var channel = await connection.CreateChannelAsync())
{
await channel.ExchangeDeclareAsync(RabbitConnection.SetQueueNameForEnv(Topics.EventingTopic), "fanout");
}

_exchangeDeclared = true;
Comment on lines +126 to +139
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.

⚠️ Potential issue | 🟠 Major

Use the same validated connection for declaration and publish.

This method now validates/declares against one IConnection, but SendMessage fetches the singleton again afterward. If a reset happens in that gap, _exchangeDeclared is cleared and publish can still continue on the recreated connection without re-declaring the exchange.

Suggested shape
-private static async Task<bool> VerifyAndCreateClients(string clientName)
+private static async Task<IConnection> VerifyAndCreateClients(string clientName)
 {
 	try
 	{
 		var connection = await RabbitConnection.CreateConnection(clientName);
+		if (connection == null)
+			return null;
 
 		if (_exchangeDeclared)
-			return true;
-
-		if (connection != null)
-		{
-			using (var channel = await connection.CreateChannelAsync())
-			{
-				await channel.ExchangeDeclareAsync(RabbitConnection.SetQueueNameForEnv(Topics.EventingTopic), "fanout");
-			}
-
-			_exchangeDeclared = true;
-		}
+			return connection;
+
+		using (var channel = await connection.CreateChannelAsync())
+		{
+			await channel.ExchangeDeclareAsync(RabbitConnection.SetQueueNameForEnv(Topics.EventingTopic), "fanout");
+		}
+
+		_exchangeDeclared = true;
+		return connection;
 	}
 	catch (Exception ex)
 	{
 		Framework.Logging.LogException(ex);
-		return false;
+		return null;
 	}
-
-	return true;
 }
var connection = await VerifyAndCreateClients(_clientName);
if (connection == null)
	return false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs` around lines
126 - 139, The exchange is being declared on a connection instance returned by
RabbitConnection.CreateConnection but SendMessage later re-fetches the singleton
connection, risking a race where the exchange flag (_exchangeDeclared) is set
for a different connection; change the flow so the same validated IConnection
instance is used for both declaration and publish: obtain the connection once
(e.g., via a VerifyAndCreateClients(_clientName) or the local variable returned
by RabbitConnection.CreateConnection), return false if null, use that connection
to CreateChannelAsync and call ExchangeDeclareAsync(Topics.EventingTopic) and
then pass that same connection/channel into the publish path (SendMessage)
instead of re-querying the singleton, and ensure _exchangeDeclared is tied to
the specific connection instance rather than a global flag.

}
}
catch (Exception ex)
Expand Down
10 changes: 7 additions & 3 deletions Web/Resgrid.Web.Services/Controllers/TwilioController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -591,14 +591,14 @@ public async Task<ActionResult> VoiceCallAction(string userId, int callId, [From

response.Say("You have been marked responding to the scene, goodbye.").Hangup();
}
else
else if (int.TryParse(twilioRequest.Digits, out var digit))
{
var call = await _callsService.GetCallByIdAsync(callId);
var stations = await _departmentGroupsService.GetAllStationGroupsForDepartmentAsync(call.DepartmentId);

int index = int.Parse(twilioRequest.Digits) - 2;
int index = digit - 2;

if (index >= 0 && index < 8)
if (index >= 0 && index < stations.Count)
{
var station = stations[index];

Expand All @@ -611,6 +611,10 @@ await _actionLogsService.SetUserActionAsync(userId, call.DepartmentId, (int)Acti
}
}
}
else
{
response.Say("Sorry, that was not a valid selection.").Redirect(new Uri(string.Format("{0}/api/Twilio/VoiceCall?userId={1}&callId={2}", Config.SystemBehaviorConfig.ResgridApiBaseUrl, userId, callId)), "GET");
}

return new ContentResult
{
Expand Down
27 changes: 10 additions & 17 deletions Web/Resgrid.Web/Areas/User/Controllers/CustomStatusesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ where key.ToString().StartsWith("buttonText_")
if (gpsValue == "on")
gps = true;

var noteType = int.Parse(form["noteType_" + i]);
var detailType = int.Parse(form["detailType_" + i]);
int.TryParse(form["noteType_" + i], out var noteType);
int.TryParse(form["detailType_" + i], out var detailType);
Comment on lines +105 to +106
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.

⚠️ Potential issue | 🟠 Major

Don’t silently coerce invalid noteType/detailType to 0.

On Line 105/106 and Line 288/289, failed parses currently become 0, which can store unintended enum values instead of rejecting bad payloads. Add explicit validation on parse failure.

Proposed fix
- int.TryParse(form["noteType_" + i], out var noteType);
- int.TryParse(form["detailType_" + i], out var detailType);
+ if (!int.TryParse(form["noteType_" + i], out var noteType) ||
+     !int.TryParse(form["detailType_" + i], out var detailType))
+ {
+     ModelState.AddModelError("Detail", "Invalid note/detail type value.");
+     continue;
+ }

Also applies to: 288-289

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Web/Resgrid.Web/Areas/User/Controllers/CustomStatusesController.cs` around
lines 105 - 106, The code in CustomStatusesController silently converts invalid
noteType/detailType parses to 0 by using int.TryParse without checking the
boolean result; update the parsing logic around the form processing (the uses of
int.TryParse for variables noteType and detailType) to validate the TryParse
return value and handle failures explicitly—either return a
BadRequest/validation error or skip the invalid item and log the problem—so an
invalid payload isn’t stored as enum value 0; apply the same change to the other
occurrence of noteType/detailType parsing later in the controller.


var detail = new CustomStateDetail();
detail.ButtonText = text;
Expand All @@ -113,15 +113,8 @@ where key.ToString().StartsWith("buttonText_")
detail.DetailType = detailType;
detail.TextColor = textColor;

if (!string.IsNullOrWhiteSpace(order))
detail.Order = int.Parse(order);
else
detail.Order = 0;

if (!string.IsNullOrWhiteSpace(baseType))
detail.BaseType = int.Parse(baseType);
else
detail.BaseType = 0;
detail.Order = int.TryParse(order, out var parsedNewOrder) ? parsedNewOrder : 0;
detail.BaseType = int.TryParse(baseType, out var parsedNewBaseType) ? parsedNewBaseType : -1;

model.State.Details.Add(detail);
}
Expand Down Expand Up @@ -217,6 +210,8 @@ public async Task<IActionResult> EditDetail(EditDetailView model, CancellationTo

model.DetailTypes = model.DetailType.ToSelectList();
model.NoteTypes = model.NoteType.ToSelectList();
model.BaseTypes = model.BaseType.ToSelectList();
model.Detail.CustomState = await _customStateService.GetCustomSateByIdAsync(model.Detail.CustomStateId);

if (ModelState.IsValid)
{
Expand Down Expand Up @@ -248,8 +243,6 @@ public async Task<IActionResult> EditDetail(EditDetailView model, CancellationTo
return RedirectToAction("Edit", new { id = detail.CustomStateId });
}

model.Detail.CustomState = await _customStateService.GetCustomSateByIdAsync(model.Detail.CustomStateId);

return View(model);
}

Expand Down Expand Up @@ -292,8 +285,8 @@ where key.ToString().StartsWith("buttonText_")
if (gpsValue == "on")
gps = true;

var noteType = int.Parse(form["noteType_" + i]);
var detailType = int.Parse(form["detailType_" + i]);
int.TryParse(form["noteType_" + i], out var noteType);
int.TryParse(form["detailType_" + i], out var detailType);

var detail = new CustomStateDetail();
detail.ButtonText = text;
Expand All @@ -302,8 +295,8 @@ where key.ToString().StartsWith("buttonText_")
detail.NoteType = noteType;
detail.DetailType = detailType;
detail.TextColor = textColor;
detail.Order = int.Parse(order);
detail.BaseType = int.Parse(baseType);
detail.Order = int.TryParse(order, out var parsedOrder) ? parsedOrder : 0;
detail.BaseType = int.TryParse(baseType, out var parsedBaseType) ? parsedBaseType : -1;

if (!string.IsNullOrWhiteSpace(customStateDetailId))
detail.CustomStateDetailId = int.Parse(customStateDetailId);
Expand Down
3 changes: 2 additions & 1 deletion Web/Resgrid.Web/Areas/User/Controllers/GroupsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,8 @@ public async Task<IActionResult> Geofence(int departmentGroupId)
if (model.Group.DepartmentId != DepartmentId)
return Unauthorized();

model.Coordinates = await _departmentGroupsService.GetMapCenterCoordinatesForGroupAsync(departmentGroupId);
model.Coordinates = await _departmentGroupsService.GetMapCenterCoordinatesForGroupAsync(departmentGroupId)
?? new Coordinates { Latitude = 39.8283, Longitude = -98.5795 };

return View(model);
}
Expand Down
3 changes: 0 additions & 3 deletions Web/Resgrid.Web/Areas/User/Views/Shifts/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

@section Styles
{
<link rel="stylesheet" href="~/lib/fullcalendar/index.global.min.css" />
<style>
.nohover {
text-decoration: none !important;
Expand Down Expand Up @@ -191,6 +190,4 @@

@section Scripts
{
<script src="~/lib/fullcalendar/index.global.min.js"></script>
<script src="~/js/app/internal/shifts/resgrid.shifts.index.js"></script>
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
@section Scripts
{
<script>
document.querySelector("input[name='Sort']").addEventListener("keypress", function (evt) {
document.querySelector("input[name='Autofill.Sort']").addEventListener("keypress", function (evt) {
if(evt.which == 8){return} // to allow BackSpace
if (evt.which < 48 || evt.which > 57)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@
function addOption() {
$('#newStatusModal').modal('hide');
resgrid.statuses.editstatus.optionsCount++;
$('#options tbody').first().append("<tr><td><input type='number' min='0' id='order_" + editstatus.optionsCount + "' name='order_" + editstatus.optionsCount + "' value='0' class='numberEntry'></td><td>" + $('#buttonText').val() + "<input type='hidden' id='buttonText_" + editstatus.optionsCount + "' name='buttonText_" + editstatus.optionsCount + "' value='" + $('#buttonText').val() + "'></input><input type='hidden' id='baseType_" + editstatus.optionsCount + "' name='baseType_" + editstatus.optionsCount + "' value='" + $('#baseType').val() + "'></input></td><td><a class='btn btn-default' role='button' style='color:" + $('#textColor').val() + ";background:" + $('#buttonColor').val() + ";'>" + $('#buttonText').val() + "</a><input type='hidden' id='buttonColor_" + editstatus.optionsCount + "' name='buttonColor_" + editstatus.optionsCount + "' value='" + $('#buttonColor').val() + "'><input type='hidden' id='textColor_" + editstatus.optionsCount + "' name='textColor_" + editstatus.optionsCount + "' value='" + $('#textColor').val() + "'><input type='hidden' id='detailType_" + editstatus.optionsCount + "' name='detailType_" + editstatus.optionsCount + "' value='" + $('#detailType').val() + "'></input><input type='hidden' id='noteType_" + editstatus.optionsCount + "' name='noteType_" + editstatus.optionsCount + "' value='" + $('#noteType').val() + "'></input><input type='hidden' id='requireGps_" + editstatus.optionsCount + "' name='requireGps_" + editstatus.optionsCount + "' value='" + $('#requireGps').val() + "'></input></td><td style='text-align:center;'><a onclick='$(this).parent().parent().remove();' class='btn btn-xs btn-danger' data-original-title='Remove this option'>Remove</a></td></tr>");
var baseTypeVal = $('#baseType').length ? $('#baseType').val() : '-1';
var detailTypeVal = $('#detailType').length ? $('#detailType').val() : '0';
var noteTypeVal = $('#noteType').length ? $('#noteType').val() : '0';
var requireGpsVal = $('#requireGps').length && $('#requireGps').is(':checked') ? 'on' : 'false';
$('#options tbody').first().append("<tr><td><input type='number' min='0' id='order_" + editstatus.optionsCount + "' name='order_" + editstatus.optionsCount + "' value='0' class='numberEntry'></td><td>" + $('#buttonText').val() + "<input type='hidden' id='buttonText_" + editstatus.optionsCount + "' name='buttonText_" + editstatus.optionsCount + "' value='" + $('#buttonText').val() + "'></input><input type='hidden' id='baseType_" + editstatus.optionsCount + "' name='baseType_" + editstatus.optionsCount + "' value='" + baseTypeVal + "'></input></td><td><a class='btn btn-default' role='button' style='color:" + $('#textColor').val() + ";background:" + $('#buttonColor').val() + ";'>" + $('#buttonText').val() + "</a><input type='hidden' id='buttonColor_" + editstatus.optionsCount + "' name='buttonColor_" + editstatus.optionsCount + "' value='" + $('#buttonColor').val() + "'><input type='hidden' id='textColor_" + editstatus.optionsCount + "' name='textColor_" + editstatus.optionsCount + "' value='" + $('#textColor').val() + "'><input type='hidden' id='detailType_" + editstatus.optionsCount + "' name='detailType_" + editstatus.optionsCount + "' value='" + detailTypeVal + "'></input><input type='hidden' id='noteType_" + editstatus.optionsCount + "' name='noteType_" + editstatus.optionsCount + "' value='" + noteTypeVal + "'></input><input type='hidden' id='requireGps_" + editstatus.optionsCount + "' name='requireGps_" + editstatus.optionsCount + "' value='" + requireGpsVal + "'></input></td><td style='text-align:center;'><a onclick='$(this).parent().parent().remove();' class='btn btn-xs btn-danger' data-original-title='Remove this option'>Remove</a></td></tr>");

Check failure

Code scanning / CodeQL

DOM text reinterpreted as HTML High

DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
DOM text
is reinterpreted as HTML without escaping meta-characters.
}
editstatus.addOption = addOption;
function isNumber(evt) {
Expand Down
Loading
Loading