fix(api): validate date-range month/day bounds on playout PUT actions
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m59s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

AlternateScheduleSelector.GetScheduleForDate constructs
new DateTime(year, StartMonth, StartDay) and only recovers from an
out-of-range *day* (rolling to the 1st of the next month); an out-of-range
*month* (e.g. 0 or 13) throws again from inside that recovery path and is
never caught, crashing playout building.

Validate StartMonth/EndMonth (1..12) and StartDay/EndDay (1..31) on both
PUT /api/playouts/{id}/alternate-schedules and PUT /api/playouts/{id}/templates
whenever an item sets LimitToDateRange, returning 422 before dispatching to
the mediator. Both actions already documented 422 in their ProducesResponseType
metadata.
This commit is contained in:
2026-07-07 20:56:45 +02:00
parent fb76f34fc6
commit d7617b7456
2 changed files with 176 additions and 0 deletions
@@ -306,6 +306,11 @@ public class PlayoutController(IMediator mediator) : ControllerBase
return BaseError.New("[Items] must contain at least one alternate schedule").ToErrorResult();
}
foreach (BaseError error in ValidateDateRanges(items.Select(ToDateRangeCheck)))
{
return error.ToErrorResult();
}
List<ProgramScheduleViewModel> schedules =
await mediator.Send(new GetAllProgramSchedules(), cancellationToken);
var scheduleIds = schedules.Select(s => s.Id).ToHashSet();
@@ -389,6 +394,11 @@ public class PlayoutController(IMediator mediator) : ControllerBase
List<PlayoutTemplateItemRequest> items = request.Items ?? [];
foreach (BaseError error in ValidateDateRanges(items.Select(ToDateRangeCheck)))
{
return error.ToErrorResult();
}
List<TemplateViewModel> templates = await mediator.Send(new GetAllTemplates(), cancellationToken);
var templateIds = templates.Select(t => t.Id).ToHashSet();
var missingTemplateIds = items.Select(i => i.TemplateId).Distinct()
@@ -444,6 +454,53 @@ public class PlayoutController(IMediator mediator) : ControllerBase
return result.ToDeletedResult();
}
// Guards against AlternateScheduleSelector crashing on out-of-range dates: it constructs
// `new DateTime(year, StartMonth, StartDay)` and only recovers from an overflowing *day*
// (rolling to the 1st of the next month); an out-of-range *month* throws again from inside
// that recovery path and is never caught. Only checked when LimitToDateRange is set, since
// the fields are ignored otherwise.
private static (bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay) ToDateRangeCheck(
PlayoutAlternateScheduleItemRequest item) =>
(item.LimitToDateRange, item.StartMonth, item.StartDay, item.EndMonth, item.EndDay);
private static (bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay) ToDateRangeCheck(
PlayoutTemplateItemRequest item) =>
(item.LimitToDateRange, item.StartMonth, item.StartDay, item.EndMonth, item.EndDay);
private static Option<BaseError> ValidateDateRanges(
IEnumerable<(bool LimitToDateRange, int StartMonth, int StartDay, int EndMonth, int EndDay)> items)
{
foreach ((bool limitToDateRange, int startMonth, int startDay, int endMonth, int endDay) in items)
{
if (!limitToDateRange)
{
continue;
}
if (startMonth is < 1 or > 12)
{
return Some(BaseError.New($"[StartMonth] {startMonth} must be between 1 and 12"));
}
if (endMonth is < 1 or > 12)
{
return Some(BaseError.New($"[EndMonth] {endMonth} must be between 1 and 12"));
}
if (startDay is < 1 or > 31)
{
return Some(BaseError.New($"[StartDay] {startDay} must be between 1 and 31"));
}
if (endDay is < 1 or > 31)
{
return Some(BaseError.New($"[EndDay] {endDay} must be between 1 and 31"));
}
}
return Option<BaseError>.None;
}
private static PlayoutResponseModel ToResponse(PlayoutNameViewModel vm) =>
PlayoutResponseModel.From(
vm.PlayoutId,