Skip to content

Commit c8c302b

Browse files
authored
Merge pull request #404 from Resgrid/develop
RE1-T117 Bug fixes
2 parents 24b241b + ab4ffe1 commit c8c302b

6 files changed

Lines changed: 42 additions & 10 deletions

File tree

Core/Resgrid.Services/Reporting/IncidentExport.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,14 @@ private static string Utc(DateTime? value)
127127
private static string Escape(string value)
128128
{
129129
value ??= string.Empty;
130+
131+
// A leading =, +, -, @, tab or CR makes Excel/Sheets evaluate the cell as a formula on
132+
// import (CSV injection). Plain numeric values (e.g. "-122.5") are exempt so coordinates
133+
// and phone numbers survive intact.
134+
if (value.Length > 0 && (value[0] == '=' || value[0] == '+' || value[0] == '-' || value[0] == '@' || value[0] == '\t' || value[0] == '\r')
135+
&& !double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
136+
value = "'" + value;
137+
130138
if (value.IndexOf('"') >= 0 || value.IndexOf(',') >= 0 || value.IndexOf('\n') >= 0 || value.IndexOf('\r') >= 0)
131139
return "\"" + value.Replace("\"", "\"\"") + "\"";
132140
return value;

Repositories/Resgrid.Repositories.DataRepository/Servers/PostgreSql/PostgreSqlConfiguration.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1175,7 +1175,7 @@ from shifts sh
11751175
SelectAllOpenCallsByDidDateQuery =
11761176
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND IsDeleted = false AND State = 0";
11771177
SelectAllCallsByDidLoggedOnQuery =
1178-
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND AND LoggedOn >= %DATE%";
1178+
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE DepartmentId = %DID% AND LoggedOn >= %DATE%";
11791179

11801180
// ----- Platform reporting / analytics (set-based; %ALLDEPTS% = 1 means system-wide) -----
11811181
SelectReportCallsCountQuery =

Repositories/Resgrid.Repositories.DataRepository/Servers/SqlServer/SqlServerConfiguration.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1128,7 +1128,7 @@ FROM [dbo].[Shifts] sh
11281128
SelectAllOpenCallsByDidDateQuery =
11291129
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND [IsDeleted] = 0 AND [State] = 0";
11301130
SelectAllCallsByDidLoggedOnQuery =
1131-
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND AND [LoggedOn] >= %DATE%";
1131+
"SELECT * FROM %SCHEMA%.%TABLENAME% WHERE [DepartmentId] = %DID% AND [LoggedOn] >= %DATE%";
11321132

11331133
// ----- Platform reporting / analytics (set-based; %ALLDEPTS% = 1 means system-wide) -----
11341134
SelectReportCallsCountQuery =

Tests/Resgrid.Tests/Services/IncidentExportTests.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,25 @@ public void BuildCsv_writes_header_and_escapes_values()
6262
csv.Should().Contain("2026-06-01T12:00:00Z");
6363
}
6464

65+
[Test]
66+
public void BuildCsv_neutralizes_leading_formula_characters()
67+
{
68+
var call = SampleCall();
69+
call.Name = "=HYPERLINK(\"http://evil.example\",\"click\")";
70+
call.NatureOfCall = "@cmd|' /C calc'!A0";
71+
call.Address = "-122.5";
72+
73+
var bytes = IncidentExport.BuildCsv(ExportProfile.Generic, new[] { call });
74+
var csv = Encoding.UTF8.GetString(bytes);
75+
76+
// Formula-leading cells are neutralized with a single-quote prefix.
77+
csv.Should().Contain("'=HYPERLINK");
78+
csv.Should().Contain("'@cmd");
79+
csv.Should().NotContain(",=HYPERLINK");
80+
// Plain negative numbers are exempt from neutralization.
81+
csv.Should().Contain(",-122.5,");
82+
}
83+
6584
[Test]
6685
public void BuildCsv_nfirs_emits_full_schema_header_with_empty_gap_cells()
6786
{

Web/Resgrid.Web.Services/Controllers/v4/ReportingController.cs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.AspNetCore.Mvc;
77
using Resgrid.Model.Reporting;
88
using Resgrid.Model.Services;
9+
using Resgrid.Providers.Claims;
910
using Resgrid.Web.Services.Helpers;
1011
using Resgrid.Web.Services.Models.v4.Reporting;
1112

@@ -18,6 +19,7 @@ namespace Resgrid.Web.Services.Controllers.v4
1819
///
1920
/// SECURITY: every endpoint is hard-scoped to the authenticated user's claim DepartmentId — there is
2021
/// deliberately no departmentId parameter, so a client can never request another department's data.
22+
/// Every endpoint also requires the caller's Reports/View permission (Reports_View policy).
2123
/// System-wide (cross-department) reporting is intentionally NOT exposed over HTTP; it is available
2224
/// only to the in-process BackOffice, which resolves IPlatformReportingService directly and calls it
2325
/// with departmentId = null.
@@ -29,6 +31,7 @@ public class ReportingController : V4AuthenticatedApiControllerbase
2931
{
3032
private const int MaxDayWindow = 366;
3133
private const int MaxMonthWindowDays = 366 * 5;
34+
private const int MaxTopN = 50;
3235

3336
private readonly IPlatformReportingService _reportingService;
3437

@@ -44,14 +47,15 @@ public ReportingController(IPlatformReportingService reportingService)
4447
/// <param name="from">Window start (UTC).</param>
4548
/// <param name="to">Window end (UTC).</param>
4649
/// <param name="granularity">Series bucketing: 0 = day, 1 = month.</param>
47-
/// <param name="topN">Max slices per breakdown before an "Other" bucket.</param>
50+
/// <param name="topN">Max slices per breakdown before an "Other" bucket (clamped to 1–50).</param>
4851
[HttpGet("GetDashboard")]
4952
[ProducesResponseType(StatusCodes.Status200OK)]
50-
[Authorize]
53+
[Authorize(Policy = ResgridResources.Reports_View)]
5154
public async Task<ActionResult<DashboardReportResult>> GetDashboard(DateTime from, DateTime to,
5255
int granularity = 0, int topN = 5, CancellationToken cancellationToken = default)
5356
{
5457
var gran = granularity == 1 ? ReportGranularity.Month : ReportGranularity.Day;
58+
topN = Math.Clamp(topN, 1, MaxTopN);
5559
var (startUtc, endUtc) = NormalizeWindow(from, to, gran == ReportGranularity.Month ? MaxMonthWindowDays : MaxDayWindow);
5660

5761
var report = await _reportingService.GetDashboardReportAsync(DepartmentId, startUtc, endUtc, gran, topN, false, cancellationToken);
@@ -65,7 +69,7 @@ public async Task<ActionResult<DashboardReportResult>> GetDashboard(DateTime fro
6569
/// <summary>Response-time / NFPA analytics (alarm handling, turnout, travel, total response).</summary>
6670
[HttpGet("GetResponseTimes")]
6771
[ProducesResponseType(StatusCodes.Status200OK)]
68-
[Authorize]
72+
[Authorize(Policy = ResgridResources.Reports_View)]
6973
public async Task<ActionResult<ResponseTimeReportResult>> GetResponseTimes(DateTime from, DateTime to,
7074
CancellationToken cancellationToken = default)
7175
{
@@ -81,7 +85,7 @@ public async Task<ActionResult<ResponseTimeReportResult>> GetResponseTimes(DateT
8185
/// <summary>Unit Hour Utilization and workload analytics.</summary>
8286
[HttpGet("GetUtilization")]
8387
[ProducesResponseType(StatusCodes.Status200OK)]
84-
[Authorize]
88+
[Authorize(Policy = ResgridResources.Reports_View)]
8589
public async Task<ActionResult<UtilizationReportResult>> GetUtilization(DateTime from, DateTime to,
8690
CancellationToken cancellationToken = default)
8791
{
@@ -97,7 +101,7 @@ public async Task<ActionResult<UtilizationReportResult>> GetUtilization(DateTime
97101
/// <summary>Personnel participation and certification-compliance analytics.</summary>
98102
[HttpGet("GetParticipation")]
99103
[ProducesResponseType(StatusCodes.Status200OK)]
100-
[Authorize]
104+
[Authorize(Policy = ResgridResources.Reports_View)]
101105
public async Task<ActionResult<ParticipationReportResult>> GetParticipation(DateTime from, DateTime to,
102106
CancellationToken cancellationToken = default)
103107
{
@@ -116,7 +120,7 @@ public async Task<ActionResult<ParticipationReportResult>> GetParticipation(Date
116120
/// </summary>
117121
[HttpGet("ExportIncidents")]
118122
[ProducesResponseType(StatusCodes.Status200OK)]
119-
[Authorize]
123+
[Authorize(Policy = ResgridResources.Reports_View)]
120124
public async Task<IActionResult> ExportIncidents(DateTime from, DateTime to, int profile = 0,
121125
CancellationToken cancellationToken = default)
122126
{
@@ -134,7 +138,7 @@ public async Task<IActionResult> ExportIncidents(DateTime from, DateTime to, int
134138
/// </summary>
135139
[HttpGet("GetExportGaps")]
136140
[ProducesResponseType(StatusCodes.Status200OK)]
137-
[Authorize]
141+
[Authorize(Policy = ResgridResources.Reports_View)]
138142
public ActionResult<ExportGapReportResult> GetExportGaps(int profile = 0)
139143
{
140144
var exportProfile = Enum.IsDefined(typeof(ExportProfile), profile) ? (ExportProfile)profile : ExportProfile.Generic;

Web/Resgrid.Web.Services/Resgrid.Web.Services.xml

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)