1+ using System . Text . RegularExpressions ;
12using Exceptionless . Core ;
23using Exceptionless . Core . Extensions ;
34using Exceptionless . Core . Models ;
78using Exceptionless . Core . Repositories . Configuration ;
89using Exceptionless . Core . Services . BackgroundTasks ;
910using Exceptionless . Core . Services . BulkOperations ;
11+ using Exceptionless . DateTimeExtensions ;
12+ using Exceptionless . DateTimeExtensions . FormatParsers ;
13+ using Exceptionless . DateTimeExtensions . FormatParsers . PartParsers ;
1014using Exceptionless . Web . Api . Infrastructure ;
1115using Exceptionless . Web . Api . Messages ;
1216using Exceptionless . Web . Api . Results ;
@@ -30,7 +34,6 @@ public class BackgroundTaskHandler(
3034 ILogger < BackgroundTaskHandler > logger )
3135{
3236 private const int MaxIds = 1000 ;
33- private static readonly ICollection < string > _allowedDateFields = new List < string > { EventIndex . Alias . Date } ;
3437
3538 private HttpContext HttpContext => httpContextAccessor . HttpContext ?? throw new InvalidOperationException ( "HttpContext is unavailable." ) ;
3639
@@ -192,10 +195,17 @@ public async Task<Result<AcceptedResponse<ViewBackgroundTask>>> Handle(CancelBac
192195 if ( model . Ids is { Count : > 0 } )
193196 return ( null , Result . Invalid ( ValidationError . Create ( "selection" , "A filter selection cannot include ids." ) ) ) ;
194197
195- // Resolve the relative time range once, now. The end can never be later than this moment, so events that
196- // arrive after the operation is created are not part of it.
197- var timeInfo = TimeRangeParser . GetTimeInfo ( model . Time , model . Offset , timeProvider , _allowedDateFields , EventIndex . Alias . Date ,
198- organization . GetRetentionUtcCutoff ( appOptions . MaximumRetentionDays , timeProvider ) ) ;
198+ if ( ! TryParseSelectionOffset ( model . Offset , out var utcOffset ) )
199+ return ( null , Result . Invalid ( ValidationError . Create ( "selection.offset" , "The UTC offset must be a valid whole-minute offset between -14h and 14h." ) ) ) ;
200+
201+ // Validate the requested range before retention and open-end defaults can turn a parse failure into all history.
202+ // Reuse the parsed range so validation and execution resolve relative dates at exactly the same instant.
203+ if ( ! TryParseSelectionTime ( model . Time , new DateTimeOffset ( utcNow ) . ToOffset ( utcOffset ) , out var range ) )
204+ return ( null , Result . Invalid ( ValidationError . Create ( "selection.time" , "The time range is not valid." ) ) ) ;
205+
206+ var timeInfo = new TimeInfo { Field = EventIndex . Alias . Date , Offset = utcOffset , Range = range ! } ;
207+ timeInfo . ApplyMinimumUtcStartDate ( organization . GetRetentionUtcCutoff ( appOptions . MaximumRetentionDays , timeProvider ) ) ;
208+ timeInfo . AdjustEndTimeIfMaxValue ( timeProvider ) ;
199209 var utcStart = timeInfo . Range . UtcStart ;
200210 var utcEnd = timeInfo . Range . UtcEnd > utcNow ? utcNow : timeInfo . Range . UtcEnd ;
201211 if ( utcStart > utcEnd )
@@ -214,6 +224,104 @@ public async Task<Result<AcceptedResponse<ViewBackgroundTask>>> Handle(CancelBac
214224 return ( selection , null ) ;
215225 }
216226
227+ internal static bool TryParseSelectionTime ( string ? time , DateTimeOffset relativeBaseTime , out DateTimeRange ? range )
228+ {
229+ if ( String . IsNullOrWhiteSpace ( time ) )
230+ {
231+ range = DateTimeRange . Empty ;
232+ return true ;
233+ }
234+
235+ string expression = time . Trim ( ) ;
236+ if ( expression . StartsWith ( $ "{ EventIndex . Alias . Date } |", StringComparison . Ordinal ) )
237+ expression = expression [ ( EventIndex . Alias . Date . Length + 1 ) .. ] ;
238+
239+ // DateTimeRange.Parse uses the same unbounded sentinel for invalid input and a valid [* TO *] range.
240+ // Its two-part parser also supplies missing bounds, so require both to have been parsed explicitly.
241+ range = null ;
242+ try
243+ {
244+ foreach ( var parser in DateTimeRange . FormatParsers )
245+ {
246+ if ( parser is TwoPartFormatParser twoPartParser )
247+ {
248+ var bounds = new List < DateTimeOffset > ( 2 ) ;
249+ var partParsers = twoPartParser . Parsers . Select < IPartParser , IPartParser > ( partParser => partParser is WildcardPartParser
250+ ? new SelectionTimeWildcardPartParser ( bounds )
251+ : new SelectionTimePartParser ( partParser , bounds ) ) ;
252+ range = new TwoPartFormatParser ( partParsers ) . Parse ( expression , relativeBaseTime ) ;
253+ if ( range is not null && ( bounds . Count != 2 || bounds [ 0 ] > bounds [ 1 ] ) )
254+ {
255+ range = null ;
256+ return false ;
257+ }
258+ }
259+ else
260+ {
261+ range = parser . Parse ( expression , relativeBaseTime ) ;
262+ }
263+
264+ if ( range is not null )
265+ return true ;
266+ }
267+ }
268+ catch ( Exception ex ) when ( ex is ArgumentException or FormatException or OverflowException )
269+ {
270+ range = null ;
271+ }
272+
273+ return false ;
274+ }
275+
276+ internal static bool TryParseSelectionOffset ( string ? offset , out TimeSpan utcOffset )
277+ {
278+ utcOffset = TimeSpan . Zero ;
279+ if ( String . IsNullOrWhiteSpace ( offset ) )
280+ return true ;
281+
282+ try
283+ {
284+ if ( ! TimeUnit . TryParse ( offset , out var parsedOffset ) || ! parsedOffset . HasValue
285+ || parsedOffset . Value < TimeSpan . FromHours ( - 14 ) || parsedOffset . Value > TimeSpan . FromHours ( 14 )
286+ || parsedOffset . Value . Ticks % TimeSpan . TicksPerMinute != 0 )
287+ return false ;
288+
289+ utcOffset = parsedOffset . Value ;
290+ return true ;
291+ }
292+ catch ( Exception ex ) when ( ex is ArgumentException or FormatException or OverflowException )
293+ {
294+ return false ;
295+ }
296+ }
297+
298+ private sealed class SelectionTimePartParser ( IPartParser inner , List < DateTimeOffset > bounds ) : IPartParser
299+ {
300+ public Regex Regex => inner . Regex ;
301+
302+ public DateTimeOffset ? Parse ( Match match , DateTimeOffset relativeBaseTime , bool isUpperLimit )
303+ {
304+ var value = inner . Parse ( match , relativeBaseTime , isUpperLimit ) ;
305+ if ( value . HasValue )
306+ bounds . Add ( value . Value ) ;
307+
308+ return value ;
309+ }
310+ }
311+
312+ // The two-part parser checks this type to keep wildcard bounds open regardless of bracket inclusivity.
313+ private sealed class SelectionTimeWildcardPartParser ( List < DateTimeOffset > bounds ) : WildcardPartParser , IPartParser
314+ {
315+ DateTimeOffset ? IPartParser . Parse ( Match match , DateTimeOffset relativeBaseTime , bool isUpperLimit )
316+ {
317+ var value = base . Parse ( match , relativeBaseTime , isUpperLimit ) ;
318+ if ( value . HasValue )
319+ bounds . Add ( value . Value ) ;
320+
321+ return value ;
322+ }
323+ }
324+
217325 private static Result < T > ToResult < T > ( BulkOperationCheckResult check )
218326 {
219327 return check . Status switch
0 commit comments