From 5695d16bea8af9e01fa9e33044dd1c07e968f6c3 Mon Sep 17 00:00:00 2001 From: MCJ Vasseur <14887731+vmcj@users.noreply.github.com> Date: Mon, 4 Aug 2025 19:35:46 +0200 Subject: [PATCH] Allow DateTime format specified in CLICS/ISO_8601 This fixes the following situation: - Upload a valid contest either via UI or API. - Shadow the contest and receive a valid CLICS start_time update - In the UI we would validate the contest and consider the contest invalid. In the contest validation we only considered the DateTime format with timezones and not offsets - The UI for rejudgings is now also locked as we validate the contest on multiple pages. To make this easier to read the regex is now also extracted to separate variables. That way it was easier to verify that our relative times are escaped correctly (+ is special for regex). Also added some smaller parts: - The `+` seems optional for relative times See the CLICS spec: https://github.com/icpc/ccs-specs/blame/master/Contest_API.md#L235. - Allow also only 1 subsecond for absolute positive URLs - We now also allow: yyyy-mm-ddThh:mm:ss(.uuu)?[+-]zz(:mm)? besides (T instead of space between date & time) yyyy-mm-dd hh:mm:ss(.uuu)?[+-]zz(:mm)? See: https://github.com/icpc/ccs-specs/blame/master/Contest_API.md#L233 - Allow for the Z shorthand for UTC - Disallow different offsets in the contest times. CLICS allows for this but we can fix this if there is ever a real usecase for it. We already did this for timezones. --- .../src/Controller/Jury/ContestController.php | 21 ++++- webapp/src/Entity/Contest.php | 2 +- .../Constraints/TimeStringValidator.php | 10 ++- .../Controller/Jury/ContestControllerTest.php | 82 ++++++++++++++++++- 4 files changed, 107 insertions(+), 8 deletions(-) diff --git a/webapp/src/Controller/Jury/ContestController.php b/webapp/src/Controller/Jury/ContestController.php index 4716857940..b8c4c5e419 100644 --- a/webapp/src/Controller/Jury/ContestController.php +++ b/webapp/src/Controller/Jury/ContestController.php @@ -956,9 +956,24 @@ private function checkTimezones(FormInterface $form): ?Response foreach (['Activate', 'Deactivate', 'Start', 'End', 'Freeze', 'Unfreeze'] as $timeString) { $tmpValue = $formData->{'get' . $timeString . 'timeString'}(); if ($tmpValue !== '' && !is_null($tmpValue)) { - $fields = explode(' ', $tmpValue); - if (count($fields) > 1) { - $timeZones[] = $fields[2]; + if (preg_match("/\d{2}-\d{2}-\d{2}.*/", $tmpValue) === 1) { + $chr = $tmpValue[10]; // The separator between date & time + $fields = explode($chr, $tmpValue); + // First field is the time, 2nd/3th might be timezone or offset + $tmpValue = substr(str_replace($fields[0], '', $tmpValue), 1); // Also remove the separator + if (str_contains($tmpValue, ' ')) { + $fields = explode(' ', $tmpValue); + } elseif (str_contains($tmpValue, '+')) { + $fields = explode('+', $tmpValue); + } elseif (str_contains($tmpValue, '-')) { + $fields = explode('-', $tmpValue); + } elseif (substr($tmpValue, -1) === 'Z') { + $timeZones[] = 'UTC'; + continue; + } + if (count($fields) > 1) { + $timeZones[] = $fields[1]; + } } } } diff --git a/webapp/src/Entity/Contest.php b/webapp/src/Entity/Contest.php index 03a7207594..0b8b4766fe 100644 --- a/webapp/src/Entity/Contest.php +++ b/webapp/src/Entity/Contest.php @@ -1275,7 +1275,7 @@ public function validate(ExecutionContextInterface $context): void $this->updateTimes(); if (Utils::difftime((float)$this->getEndtime(), (float)$this->getStarttime(true)) <= 0) { $context - ->buildViolation('Contest ends before it even starts') + ->buildViolation('Contest ends before it even starts.') ->atPath('endtimeString') ->addViolation(); } diff --git a/webapp/src/Validator/Constraints/TimeStringValidator.php b/webapp/src/Validator/Constraints/TimeStringValidator.php index 9a47c47b4d..f7ca0bff20 100644 --- a/webapp/src/Validator/Constraints/TimeStringValidator.php +++ b/webapp/src/Validator/Constraints/TimeStringValidator.php @@ -10,6 +10,10 @@ class TimeStringValidator extends ConstraintValidator { public function validate(mixed $value, Constraint $constraint): void { + $timezoneRegex = "[A-Za-z][A-Za-z0-9_\/+-]{1,35}"; # See: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + $offsetRegex = "[+-]\d{1,2}(:\d\d)?"; + $absoluteRegex = "\d\d\d\d-\d\d-\d\d( |T)\d\d:\d\d:\d\d(\.\d{1,6})?( " . $timezoneRegex . "|" . $offsetRegex . "|Z)"; + $relativeRegex = "\d+:\d\d(:\d\d(\.\d{1,6})?)?"; if (!$constraint instanceof TimeString) { throw new UnexpectedTypeException($constraint, TimeString::class); } @@ -24,11 +28,11 @@ public function validate(mixed $value, Constraint $constraint): void if ($constraint->allowRelative) { $regex = $constraint->relativeIsPositive ? - "/^(\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d(\.\d{1,6})? [A-Za-z][A-Za-z0-9_\/+-]{1,35}|\+\d+:\d\d(:\d\d(\.\d{1,6})?)?)$/" : - "/^(\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d(\.\d{1,6})? [A-Za-z][A-Za-z0-9_\/+-]{1,35}|-\d+:\d\d(:\d\d(\.\d{1,6})?)?)$/"; + "/^(" . $absoluteRegex . "|\+?" . $relativeRegex . ")$/" : + "/^(" . $absoluteRegex . "|-" . $relativeRegex . ")$/"; $message = $constraint->absoluteRelativeMessage; } else { - $regex = "/^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d(\.\d{1,6})? [A-Za-z][A-Za-z0-9_\/+-]{1,35}$/"; + $regex = "/^" . $absoluteRegex . "$/"; $message = $constraint->absoluteMessage; } if (preg_match($regex, $value) !== 1) { diff --git a/webapp/tests/Unit/Controller/Jury/ContestControllerTest.php b/webapp/tests/Unit/Controller/Jury/ContestControllerTest.php index a3b0fba1cf..b3ed5e127d 100644 --- a/webapp/tests/Unit/Controller/Jury/ContestControllerTest.php +++ b/webapp/tests/Unit/Controller/Jury/ContestControllerTest.php @@ -49,6 +49,54 @@ class ContestControllerTest extends JuryControllerTestCase 'silverMedals' => '1', 'bronzeMedals' => '1', 'medalCategories' => ['0' => '2']], + ['shortname' => 'CLICS_offset_HMM', + 'name' => 'No Timezone but only offset', + 'activatetimeString' => '2021-07-17 16:08:00+1:11', + 'starttimeString' => '2021-07-17 16:09:00+1:11', + 'freezetimeString' => '2021-07-17 16:10:00+1:11', + 'endtimeString' => '2021-07-17 16:11:00+1:11', + 'unfreezetimeString' => '2021-07-17T16:12:00+1:11', + 'deactivatetimeString' => '2021-07-17T16:13:00+1:11'], + ['shortname' => 'CLICS_offset_HHMM', + 'name' => 'No Timezone but only offset', + 'activatetimeString' => '2021-07-17 16:08:00+11:11', + 'starttimeString' => '2021-07-17 16:09:00+11:11', + 'freezetimeString' => '2021-07-17 16:10:00+11:11', + 'endtimeString' => '2021-07-17 16:11:00+11:11', + 'unfreezetimeString' => '2021-07-17T16:12:00+11:11', + 'deactivatetimeString' => '2021-07-17T16:13:00+11:11'], + ['shortname' => 'CLICS_offset_0H00', + 'name' => 'No Timezone but only offset', + 'activatetimeString' => '2021-07-17 16:08:00+01:00', + 'starttimeString' => '2021-07-17 16:09:00+01:00', + 'freezetimeString' => '2021-07-17 16:10:00+01:00', + 'endtimeString' => '2021-07-17 16:11:00+01:00', + 'unfreezetimeString' => '2021-07-17T16:12:00+01:00', + 'deactivatetimeString' => '2021-07-17T16:13:00+01:00'], + ['shortname' => 'CLICS_offset_H', + 'name' => 'No Timezone but only offset', + 'activatetimeString' => '2021-07-17 16:08:00+1', + 'starttimeString' => '2021-07-17 16:09:00+1', + 'freezetimeString' => '2021-07-17 16:10:00+1', + 'endtimeString' => '2021-07-17 16:11:00+1', + 'unfreezetimeString' => '2021-07-17T16:12:00+1', + 'deactivatetimeString' => '2021-07-17T16:13:00+1'], + ['shortname' => 'CLICS_offset_-HHH', + 'name' => 'No Timezone but only offset', + 'activatetimeString' => '2021-07-17 16:08:00-01', + 'starttimeString' => '2021-07-17 16:09:00-01', + 'freezetimeString' => '2021-07-17 16:10:00-01', + 'endtimeString' => '2021-07-17 16:11:00-01', + 'unfreezetimeString' => '2021-07-17T16:12:00-01', + 'deactivatetimeString' => '2021-07-17T16:13:00-01'], + ['shortname' => 'utc_Z', + 'name' => 'UTC (Z)', + 'activatetimeString' => '2021-07-17 16:08:00Z', + 'starttimeString' => '2021-07-17 16:09:00.0Z', + 'freezetimeString' => '2021-07-17 16:10:00.00Z', + 'endtimeString' => '2021-07-17 16:11:00.000Z', + 'unfreezetimeString' => '2021-07-17T16:12:00.1Z', + 'deactivatetimeString' => '2021-07-17T16:13:00.2Z'], ['shortname' => 'otzcet', 'name' => 'Other timezone (CET)', 'activatetimeString' => '2021-07-17 16:08:00 CET', @@ -57,6 +105,14 @@ class ContestControllerTest extends JuryControllerTestCase 'endtimeString' => '2021-07-17 16:11:00 CET', 'unfreezetimeString' => '2021-07-17 16:12:00 CET', 'deactivatetimeString' => '2021-07-17 16:13:00 CET'], + ['shortname' => 'otzAfricaPorto-Novo', + 'name' => 'Other timezone (Africa/Porto-Novo)', + 'activatetimeString' => '2021-07-17 16:08:00 Africa/Porto-Novo', + 'starttimeString' => '2021-07-17 16:09:00 Africa/Porto-Novo', + 'freezetimeString' => '2021-07-17 16:10:00 Africa/Porto-Novo', + 'endtimeString' => '2021-07-17 16:11:00 Africa/Porto-Novo', + 'unfreezetimeString' => '2021-07-17 16:12:00 Africa/Porto-Novo', + 'deactivatetimeString' => '2021-07-17 16:13:00 Africa/Porto-Novo'], ['shortname' => 'otzunder', 'name' => 'Other timezone (Underscore)', 'activatetimeString' => '2021-07-17 16:08:00 America/Porto_Velho', @@ -73,6 +129,14 @@ class ContestControllerTest extends JuryControllerTestCase 'endtimeString' => '2021-07-17 16:11:00 Etc/GMT-3', 'unfreezetimeString' => '', 'deactivatetimeString' => ''], + ['shortname' => 'otzGMT2', + 'name' => 'Other timezone (GMT)', + 'activatetimeString' => '2021-07-17 16:08:00 Etc/GMT+3', + 'starttimeString' => '2021-07-17 16:09:00 Etc/GMT+3', + 'freezetimeString' => '2021-07-17 16:10:00 Etc/GMT+3', + 'endtimeString' => '2021-07-17 16:11:00 Etc/GMT+3', + 'unfreezetimeString' => '', + 'deactivatetimeString' => ''], ['shortname' => 'otzrel', 'name' => 'Other timezone (Relative)', 'activatetimeString' => '-10:00', @@ -81,6 +145,14 @@ class ContestControllerTest extends JuryControllerTestCase 'endtimeString' => '+1111:11', 'unfreezetimeString' => '', 'deactivatetimeString' => ''], + ['shortname' => 'other_split_char', + 'name' => 'Other CLICS splitchar', + 'activatetimeString' => '-10:00', + 'starttimeString' => '2021-07-17T16:09:00 Atlantic/Reykjavik', + 'freezetimeString' => '+0:01', + 'endtimeString' => '+1111:11', + 'unfreezetimeString' => '', + 'deactivatetimeString' => ''], ['shortname' => 'nofr', 'name' => 'No Freeze', 'freezetimeString' => '', @@ -93,7 +165,7 @@ class ContestControllerTest extends JuryControllerTestCase 'endtimeString' => '2021-07-17 16:11:00 Europe/Amsterdam', 'unfreezetimeString' => '', 'deactivatetimeString' => ''], - ['shortname' => 'dirfreeze', + ['shortname' => 'dirfreeze', 'name' => 'Direct freeze minimal', 'activatetimeString' => '2021-07-17 16:07:59 Europe/Amsterdam', 'starttimeString' => '2021-07-17 16:08:00 Europe/Amsterdam', @@ -109,6 +181,14 @@ class ContestControllerTest extends JuryControllerTestCase 'endtimeString' => '+10:00', 'unfreezetimeString' => '+25:00', 'deactivatetimeString' => ''], + ['shortname' => 'dirfreezerelnoplus', + 'name' => 'Direct freeze minimal relative', + 'activatetimeString' => '-0:00', + 'starttimeString' => '2021-07-17 16:08:00 Europe/Amsterdam', + 'freezetimeString' => '0:00', + 'endtimeString' => '+10:00', + 'unfreezetimeString' => '25:00', + 'deactivatetimeString' => ''], ['shortname' => 'rel', 'name' => 'Relative contest', 'activatetimeString' => '-1:00',