Properly format Custom Range in date range selector (#7439)
What changed, and why it matters
This commit fixes how a custom date range is displayed and submitted in BTCPay Server's web interface. It changes the way the browser's timezone is sent back to the server and how date/time inputs are pre-filled. There is no clear security bug being fixed; it appears to be a user-experience and correctness improvement for date filtering.
Treat as a routine UI/UX fix. If reviewing for security, verify that the new FilterCommand value is validated server-side (TimeZones.TryGet) and that the date input values are rendered safely in Razor to avoid XSS. No immediate security action is indicated by the diff alone.
Security signals we found
No explicit security relevance stated in commit title or message
Changes input handling and form submission for date/time filters
Removes a named form input (TimeZone) and replaces it with a submit button carrying a structured command
Adjusts timezone normalization in date parsing (Unspecified + tz -> UTC)
No input validation/sanitization changes visible in diff
Evidence from the diff
The patch refactors the DateRangeSelector component: it removes the direct IUrlHelper dependency, adds computed StartDate/EndDate input values formatted as yyyy-MM-ddTHH:mm:ss, and changes timezone submission from a plain input named ‘TimeZone’ to a hidden submit button with FilterCommand=’set-timezone:…’. It also updates SearchString.GetFilterDate so that Unspecified-kind dates parsed with a timezone are converted to UTC, and adjusts Pager/Default.cshtml to carry searchText in query strings. Tests are updated to expect timezone persistence after clearing filters and to assert offset equality.
Changed components
BTCPayServer/Components/DateRangeSelector/DateRangeSelector.csBTCPayServer/Components/DateRangeSelector/Default.cshtmlBTCPayServer/Components/Pager/Default.cshtmlBTCPayServer/Models/BasePagingViewModel.csBTCPayServer/SearchString.csInspect captured patch +82 / −32
diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs
index d813fc6..b6086d3 100644
--- a/BTCPayServer.Tests/FastTests.cs
+++ b/BTCPayServer.Tests/FastTests.cs
@@ -1556,19 +1556,27 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
{
var utc = TimeZoneInfo.Utc;
var search = new SearchString("startdate:2026-01-15 10:00:00");
- Assert.Equal(
+
+ void AssertEqual(DateTimeOffset a, DateTimeOffset? b)
+ {
+ Assert.NotNull(b);
+ Assert.Equal(a, b);
+ Assert.Equal(a.Offset, b.Value.Offset);
+ }
+
+ AssertEqual(
new DateTimeOffset(2026, 1, 15, 10, 0, 0, TimeSpan.Zero),
search.GetFilterDate("startdate", utc));
var paris = TimeZoneInfo.FindSystemTimeZoneById("Europe/Paris");
search = new SearchString("startdate:2026-01-15 10:00:00");
- Assert.Equal(
+ AssertEqual(
new DateTimeOffset(2026, 1, 15, 9, 0, 0, TimeSpan.Zero),
search.GetFilterDate("startdate", paris));
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
search = new SearchString("startdate:2026-06-30 17:00:00");
- Assert.Equal(
+ AssertEqual(
new DateTimeOffset(2026, 6, 30, 8, 0, 0, TimeSpan.Zero),
search.GetFilterDate("startdate", tokyo));
}
diff --git a/BTCPayServer.Tests/WalletTests.cs b/BTCPayServer.Tests/WalletTests.cs
index 38efb32..70ab9f2 100644
--- a/BTCPayServer.Tests/WalletTests.cs
+++ b/BTCPayServer.Tests/WalletTests.cs
@@ -1202,7 +1202,7 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
var urlAfterClearAll = new Uri(s.Page.Url);
var qsAfterClearAll = HttpUtility.ParseQueryString(urlAfterClearAll.Query);
Assert.True(string.IsNullOrEmpty(qsAfterClearAll["SearchText"]));
- Assert.True(string.IsNullOrEmpty(qsAfterClearAll["SearchTerm"]));
+ Assert.Equal(qsAfterClearAll["SearchTerm"], $"timezone:{selectedTimeZone}");
await s.GoToInvoices(s.StoreId);
await s.Page.ClickAsync("#DateRangeSelector");
diff --git a/BTCPayServer/Components/DateRangeSelector/DateRangeSelector.cs b/BTCPayServer/Components/DateRangeSelector/DateRangeSelector.cs
index defff74..bad9026 100644
--- a/BTCPayServer/Components/DateRangeSelector/DateRangeSelector.cs
+++ b/BTCPayServer/Components/DateRangeSelector/DateRangeSelector.cs
@@ -16,17 +16,17 @@ public class DateRangeSelector : ViewComponent
{
Search = search ?? throw new ArgumentNullException(nameof(search)),
CustomRangeTitle = customRangeTitle ?? "Filter by Custom Range",
- Url = Url
});
}
public class DateRangeSelectorModel
{
private const string SearchTermRouteKey = "searchTerm";
+ private const string InputDateFormat = "yyyy-MM-ddTHH:mm:ss";
+ private (DateTimeOffset? StartDate, DateTimeOffset? EndDate)? _dateRange;
public required SearchString Search { get; init; }
public required string CustomRangeTitle { get; init; }
- public required IUrlHelper Url { get; init; }
public DateRangeSelectorModel()
@@ -50,6 +50,24 @@ public class DateRangeSelectorModel
public bool HasDateRange(string value) => Search.HasArrayFilter("daterange", value);
+ public (DateTimeOffset? StartDate, DateTimeOffset? EndDate) DateRange =>
+ _dateRange ??= Search.GetDateRange(TimeZoneInfo.Utc);
+
+ public string? StartDateInputValue => FormatInputDate(LocalDate(DateRange.StartDate));
+ public string? EndDateInputValue => FormatInputDate(LocalDate(DateRange.EndDate));
+
+ private DateTimeOffset? LocalDate(DateTimeOffset? d)
+ {
+ if (d is null)
+ return null;
+ if (BTCPayServer.TimeZones.TryGet(Search.GetExplicitTimeZone() ?? "") is {} tz)
+ d = TimeZoneInfo.ConvertTime(d.Value, tz);
+ return d;
+ }
+
+ private static string? FormatInputDate(DateTimeOffset? date) =>
+ date?.ToString(InputDateFormat, CultureInfo.InvariantCulture);
+
public static string RemoveDatePreset(string? search)
{
var s = new SearchString(search);
diff --git a/BTCPayServer/Components/DateRangeSelector/Default.cshtml b/BTCPayServer/Components/DateRangeSelector/Default.cshtml
index 7b1c2fd..f72a942 100644
--- a/BTCPayServer/Components/DateRangeSelector/Default.cshtml
+++ b/BTCPayServer/Components/DateRangeSelector/Default.cshtml
@@ -15,7 +15,6 @@
("thisyear", StringLocalizer["This year"].Value),
("lastyear", StringLocalizer["Last year"].Value)
};
-
}
<div class="dropdown" id="DateRangeDropdown">
@@ -29,7 +28,22 @@
}
else
{
- <span text-translate="true">Custom range</span>
+ if (Model.DateRange.StartDate is not null && Model.DateRange.EndDate is not null)
+ {
+ <span>@ViewLocalizer["From {0} to {1}", Model.DateRange.StartDate.Value.ToBrowserDate(), Model.DateRange.EndDate.Value.ToBrowserDate()]</span>
+ }
+ else if (Model.DateRange.StartDate is not null)
+ {
+ <span>@ViewLocalizer["From {0}", Model.DateRange.StartDate.Value.ToBrowserDate()]</span>
+ }
+ else if (Model.DateRange.EndDate is not null)
+ {
+ <span>@ViewLocalizer["To {0}", Model.DateRange.EndDate.Value.ToBrowserDate()]</span>
+ }
+ else
+ {
+ <span text-translate="true">Custom range</span>
+ }
}
}
else
@@ -39,9 +53,10 @@
</button>
<div class="dropdown-menu" aria-labelledby="DateRangeSelector">
<div class="px-3 py-2" style="min-width: 18rem;">
+ <button type="submit" id="DateRangeTimeZoneButton" name="FilterCommand" class="d-none"></button>
<label for="DateRangeTimeZone" class="form-label small text-muted mb-1" text-translate="true">Query timezone</label>
<!-- The input will get filled automatically by the browser (thanks to the timezone attribute) if the search string doesn't have a timezone -->
- <input id="DateRangeTimeZone" name="TimeZone" class="form-control form-control-sm" timezone value="@Model.Search.GetExplicitTimeZone()" list="DateRangeTimeZoneOptions" autocomplete="off" />
+ <input id="DateRangeTimeZone" class="form-control form-control-sm" timezone value="@Model.Search.GetExplicitTimeZone()" list="DateRangeTimeZoneOptions" autocomplete="off" />
<datalist id="DateRangeTimeZoneOptions">
@foreach (var timeZone in Model.TimeZones)
{
@@ -82,7 +97,8 @@
<div class="col-sm-9">
<div class="input-group">
<input id="dtpStartDate" class="form-control flatdtpicker" type="datetime-local"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "defaultHour": 0 }'
+ data-fdtp='{ "altInput": true, "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "minuteIncrement": 1, "defaultHour": 0 }'
+ value="@Model.StartDateInputValue"
placeholder="@StringLocalizer["Start Date"]" />
<button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
<vc:icon symbol="close" />
@@ -95,7 +111,8 @@
<div class="col-sm-9">
<div class="input-group">
<input id="dtpEndDate" class="form-control flatdtpicker" type="datetime-local"
- data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "defaultHour": 0 }'
+ data-fdtp='{ "altInput": true, "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "minuteIncrement": 1, "defaultHour": 0 }'
+ value="@Model.EndDateInputValue"
placeholder="@StringLocalizer["End Date"]" />
<button type="button" class="btn btn-secondary input-group-clear" title="@StringLocalizer["Clear"]">
<vc:icon symbol="close" />
@@ -115,7 +132,9 @@
(function() {
var dateRangeTimeZone = document.getElementById('DateRangeTimeZone');
var defaultOption = document.getElementById('DateRangeTimeZone-DefaultOption');
- var defaultValue = Intl.DateTimeFormat().resolvedOptions().timeZone + ' (' + @Safe.Json(StringLocalizer["Default"].Value) + ')';
+ var tzSubmit = document.getElementById("DateRangeTimeZoneButton");
+ var browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ var defaultValue = browserTz + ' (' + @Safe.Json(StringLocalizer["Default"].Value) + ')';
defaultOption.value = defaultValue;
var expicitTimeZone = @Safe.Json(Model.Search.GetExplicitTimeZone());
@@ -126,25 +145,31 @@
dateRangeTimeZone.value = window.defaultDateTimeFormat.timeZone ?? defaultValue;
}
- function updatePreferredTimeZone() {
+ if (!window.getPreferredTimeZone() && browserTz === dateRangeTimeZone.value)
+ {
+ dateRangeTimeZone.value = defaultOption.value;
+ }
+
+ function updateTimeZone() {
if (dateRangeTimeZone.value === defaultValue || dateRangeTimeZone.value === "") {
- dateRangeTimeZone.value = defaultValue;
+ tzSubmit.value = "set-timezone:" + browserTz;
window.clearPreferredTimeZone();
} else {
+ tzSubmit.value = "set-timezone:" + dateRangeTimeZone.value;
window.setPreferredTimeZone(dateRangeTimeZone.value);
}
}
dateRangeTimeZone.addEventListener('change', function () {
- updatePreferredTimeZone();
- this.closest('form').submit();
+ updateTimeZone();
+ this.closest('form').requestSubmit(tzSubmit);
});
dateRangeTimeZone.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
event.preventDefault();
- updatePreferredTimeZone();
- this.closest('form').submit();
+ updateTimeZone();
+ this.closest('form').requestSubmit(tzSubmit);
}
});
diff --git a/BTCPayServer/Components/Pager/Default.cshtml b/BTCPayServer/Components/Pager/Default.cshtml
index 2899e89..aa16792 100644
--- a/BTCPayServer/Components/Pager/Default.cshtml
+++ b/BTCPayServer/Components/Pager/Default.cshtml
@@ -78,13 +78,9 @@
{
{ "searchTerm", Model.SearchTerm },
{ "skip", skip },
- { "count", count }
+ { "count", count },
+ { "searchText", Model.SearchText }
};
- if (Model.TimeZone is not null)
- {
- if (TimeZones.TryGet(Model.TimeZone) is {} tz)
- query.Add("timeZone", tz.Id);
- }
if (Model.PaginationQuery != null)
{
diff --git a/BTCPayServer/Models/BasePagingViewModel.cs b/BTCPayServer/Models/BasePagingViewModel.cs
index b0f2880..9e10377 100644
--- a/BTCPayServer/Models/BasePagingViewModel.cs
+++ b/BTCPayServer/Models/BasePagingViewModel.cs
@@ -39,15 +39,9 @@ namespace BTCPayServer.Models
public string SearchText { get; set; }
public string FilterCommand { get; set; }
- public string TimeZone { get; set; }
-
public SearchString GetSearch()
{
var search = SearchString.Combine([SearchTerm, SearchText]);
- if (TimeZone is not null)
- {
- search.SetFilter("timezone", TimeZones.TryGet(TimeZone)?.Id);
- }
if (FilterCommand is not null)
RunFilterCommand(search);
AddUIFilters(search);
@@ -72,6 +66,13 @@ namespace BTCPayServer.Models
}
}
+ if (FilterCommand.StartsWith("set-timezone:"))
+ {
+ var tz = FilterCommand.Substring("set-timezone:".Length);
+ if (TimeZones.TryGet(tz) is { } tzo)
+ search.SetFilter("timezone", tzo.Id);
+ }
+
if (FilterCommand.StartsWith("unset:"))
{
var k = FilterCommand.Substring("unset:".Length);
@@ -80,7 +81,10 @@ namespace BTCPayServer.Models
if (FilterCommand is "reset")
{
+ var tz = search.GetFilterString("timezone");
search.Filters.Clear();
+ if (tz != null)
+ search.SetFilter("timezone", tz);
search.TextSearch = "";
return;
}
diff --git a/BTCPayServer/SearchString.cs b/BTCPayServer/SearchString.cs
index 78f1bb9..7f6cb41 100644
--- a/BTCPayServer/SearchString.cs
+++ b/BTCPayServer/SearchString.cs
@@ -169,7 +169,7 @@ namespace BTCPayServer
{
DateTimeKind.Local => new DateTimeOffset(localDateTime, TimeZoneInfo.Local.GetUtcOffset(localDateTime)),
DateTimeKind.Utc => new DateTimeOffset(localDateTime, TimeSpan.Zero),
- DateTimeKind.Unspecified when tz is not null => new DateTimeOffset(localDateTime, tz.GetUtcOffset(localDateTime)),
+ DateTimeKind.Unspecified when tz is not null => new DateTimeOffset(localDateTime, tz.GetUtcOffset(localDateTime)).ToUniversalTime(),
DateTimeKind.Unspecified when tz is null => null,
_ => throw new ArgumentOutOfRangeException()
};
@@ -202,7 +202,6 @@ namespace BTCPayServer
var localDate = (key, val) switch
{
("startdate", "today") => today,
- ("enddate", "today") => today.AddDays(1).AddTicks(-1),
("startdate", "yesterday") => today.AddDays(-1),
("enddate", "yesterday") => today.AddTicks(-1),
("startdate", "thisweek") => startOfThisWeek,
Why this scored 18/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.