From 17acbd3bc0a4e2ece286c161b1aa3401b060cac5 Mon Sep 17 00:00:00 2001 From: Fabian Date: Sun, 18 Oct 2026 05:20:26 +0100 Subject: [PATCH] Fix activation checks across start-date boundaries (#395) --- GameEngine/Account.php | 2 +- Templates/activate/activate.tpl | 16 +- activate.php | 7 +- src/Utils/DateTime.php | 53 +++++- tests/regression/ServerStartTimeTest.php | 210 +++++++++++++++++++++++ 5 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 tests/regression/ServerStartTimeTest.php diff --git a/GameEngine/Account.php b/GameEngine/Account.php index 9c1fbe90..b3b9287a 100755 --- a/GameEngine/Account.php +++ b/GameEngine/Account.php @@ -223,7 +223,7 @@ class Account { global $database; // ==================== VERIFICARE DATA DE START A SERVERULUI ==================== - if (START_DATE < date('d.m.Y') || (START_DATE === date('d.m.Y') && START_TIME <= date('H:i'))) { + if (\App\Utils\DateTime::hasServerStarted(START_DATE, START_TIME)) { // Caută codul de activare în tabela activate $code = trim($_POST['id'] ?? ''); diff --git a/Templates/activate/activate.tpl b/Templates/activate/activate.tpl index 0a3ebaf1..74f1cb87 100644 --- a/Templates/activate/activate.tpl +++ b/Templates/activate/activate.tpl @@ -45,10 +45,10 @@ if(isset($show)){

- date('d.m.y') || START_DATE == date('d.m.y') && START_TIME <= date('H:i')){ ?> +
+}elseif($serverHasStarted){ ?>

@@ -205,4 +205,4 @@ CountBack(gsecs);

\ No newline at end of file +?> diff --git a/activate.php b/activate.php index ae376d90..89848934 100644 --- a/activate.php +++ b/activate.php @@ -25,6 +25,9 @@ use App\Utils\AccessLogger; include('GameEngine/Account.php'); AccessLogger::logRequest(); +$serverStartTimestamp = \App\Utils\DateTime::getServerStartTimestamp(START_DATE, START_TIME); +$serverHasStarted = $serverStartTimestamp !== null && time() >= $serverStartTimestamp; + ?> @@ -56,7 +59,7 @@ AccessLogger::logRequest();
- \ No newline at end of file + diff --git a/src/Utils/DateTime.php b/src/Utils/DateTime.php index 467da31b..3095e3be 100644 --- a/src/Utils/DateTime.php +++ b/src/Utils/DateTime.php @@ -23,6 +23,57 @@ namespace App\Utils; */ class DateTime { + /** + * Parse the configured game-world start using the server timezone. + * + * The installer writes START_DATE as d.m.Y and START_TIME as H:i. Parsing + * them together avoids the month/year ordering bugs caused by comparing + * the formatted values as strings. + * + * @return int|null Unix timestamp, or null when the configuration is invalid. + */ + public static function getServerStartTimestamp($startDate, $startTime, $timezone = null) + { + if (!$timezone instanceof \DateTimeZone) { + $timezone = new \DateTimeZone(date_default_timezone_get()); + } + + $value = trim((string) $startDate) . ' ' . trim((string) $startTime); + + try { + $start = \DateTimeImmutable::createFromFormat('!d.m.Y H:i', $value, $timezone); + } catch (\ValueError $exception) { + return null; + } + + $errors = \DateTimeImmutable::getLastErrors(); + + if ( + $start === false + || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) + || $start->format('d.m.Y H:i') !== $value + ) { + return null; + } + + return $start->getTimestamp(); + } + + /** + * Return true from the first second of the configured start minute. + */ + public static function hasServerStarted($startDate, $startTime, $now = null, $timezone = null) + { + $start = self::getServerStartTimestamp($startDate, $startTime, $timezone); + if ($start === null) { + return false; + } + + $now = $now === null ? time() : (int) $now; + + return $now >= $start; + } + public static function getTimeFormat($time) { $min = 0; @@ -42,4 +93,4 @@ class DateTime { return $days ." day ".$hr."h ".$min."m ".$time."s"; } -} \ No newline at end of file +} diff --git a/tests/regression/ServerStartTimeTest.php b/tests/regression/ServerStartTimeTest.php new file mode 100644 index 00000000..b465f336 --- /dev/null +++ b/tests/regression/ServerStartTimeTest.php @@ -0,0 +1,210 @@ +getTimestamp(); +} + +$lisbon = new DateTimeZone('Europe/Lisbon'); +$utc = new DateTimeZone('UTC'); +$kathmandu = new DateTimeZone('Asia/Kathmandu'); + +$decisionCases = [ + ['one second before', '20.09.2026', '12:00', '2026-09-20 11:59:59', false, $lisbon], + ['exact start second', '20.09.2026', '12:00', '2026-09-20 12:00:00', true, $lisbon], + ['inside start minute', '20.09.2026', '12:00', '2026-09-20 12:00:59', true, $lisbon], + ['one second after minute', '20.09.2026', '12:00', '2026-09-20 12:01:00', true, $lisbon], + ['earlier same day', '20.09.2026', '08:30', '2026-09-20 12:00:00', true, $lisbon], + ['later same day', '20.09.2026', '18:30', '2026-09-20 12:00:00', false, $lisbon], + ['previous day', '19.09.2026', '23:59', '2026-09-20 00:00:00', true, $lisbon], + ['next day', '21.09.2026', '00:00', '2026-09-20 23:59:59', false, $lisbon], + ['previous month', '25.08.2026', '12:00', '2026-09-20 12:00:00', true, $lisbon], + ['next month', '02.10.2026', '12:00', '2026-09-20 12:00:00', false, $lisbon], + ['previous year', '31.12.2025', '23:59', '2026-01-01 00:00:00', true, $lisbon], + ['next year', '01.01.2027', '00:00', '2026-12-31 23:59:59', false, $lisbon], + ['valid leap day exact', '29.02.2024', '00:00', '2024-02-29 00:00:00', true, $utc], + ['UTC boundary', '01.01.1970', '00:00', '1970-01-01 00:00:00', true, $utc], + ['positive offset before', '01.01.2026', '00:00', '2025-12-31 23:59:59', false, $kathmandu], + ['positive offset exact', '01.01.2026', '00:00', '2026-01-01 00:00:00', true, $kathmandu], +]; + +foreach ($decisionCases as [$label, $date, $time, $now, $expected, $timezone]) { + $actual = TravianDateTime::hasServerStarted( + $date, + $time, + test_timestamp($now, $timezone), + $timezone + ); + + if ($actual !== $expected) { + fail_server_start_test( + $label . ': expected ' . var_export($expected, true) + . ', got ' . var_export($actual, true) + ); + } +} + +$validParseCases = [ + ['epoch', '01.01.1970', '00:00', $utc, 0], + [ + 'leap day', + '29.02.2024', + '23:59', + $utc, + test_timestamp('2024-02-29 23:59:00', $utc), + ], + [ + 'leap century', + '29.02.2000', + '12:00', + $utc, + test_timestamp('2000-02-29 12:00:00', $utc), + ], + [ + 'last minute of year', + '31.12.2026', + '23:59', + $lisbon, + test_timestamp('2026-12-31 23:59:00', $lisbon), + ], + [ + 'surrounding whitespace', + ' 20.09.2026 ', + ' 12:00 ', + $lisbon, + test_timestamp('2026-09-20 12:00:00', $lisbon), + ], + [ + 'non-hour timezone', + '01.01.2026', + '00:00', + $kathmandu, + test_timestamp('2026-01-01 00:00:00', $kathmandu), + ], + [ + 'DST repeated local time', + '25.10.2026', + '01:30', + $lisbon, + test_timestamp('2026-10-25T01:30:00+00:00', $utc), + ], +]; + +foreach ($validParseCases as [$label, $date, $time, $timezone, $expected]) { + $actual = TravianDateTime::getServerStartTimestamp($date, $time, $timezone); + if ($actual !== $expected) { + fail_server_start_test( + $label . ': expected timestamp ' . $expected . ', got ' . var_export($actual, true) + ); + } +} + +// Exercise every possible H:i value accepted by the configuration format. +for ($hour = 0; $hour < 24; $hour++) { + for ($minute = 0; $minute < 60; $minute++) { + $time = sprintf('%02d:%02d', $hour, $minute); + $expected = test_timestamp('2026-06-15 ' . $time . ':00', $utc); + $actual = TravianDateTime::getServerStartTimestamp('15.06.2026', $time, $utc); + + if ($actual !== $expected) { + fail_server_start_test( + 'clock sweep ' . $time . ': expected timestamp ' . $expected + . ', got ' . var_export($actual, true) + ); + } + } +} + +// Exercise every calendar day across a common year and a leap year. Each +// parsed timestamp is checked immediately before, at, and after the boundary. +$day = new DateTimeImmutable('2023-01-01 12:34:00', $utc); +$lastDay = new DateTimeImmutable('2024-12-31 12:34:00', $utc); +while ($day <= $lastDay) { + $date = $day->format('d.m.Y'); + $expected = $day->getTimestamp(); + $actual = TravianDateTime::getServerStartTimestamp($date, '12:34', $utc); + + if ($actual !== $expected) { + fail_server_start_test( + 'calendar sweep ' . $date . ': expected timestamp ' . $expected + . ', got ' . var_export($actual, true) + ); + } + + if (TravianDateTime::hasServerStarted($date, '12:34', $expected - 1, $utc)) { + fail_server_start_test('calendar sweep ' . $date . ': started one second early'); + } + if (!TravianDateTime::hasServerStarted($date, '12:34', $expected, $utc)) { + fail_server_start_test('calendar sweep ' . $date . ': not started at exact boundary'); + } + if (!TravianDateTime::hasServerStarted($date, '12:34', $expected + 1, $utc)) { + fail_server_start_test('calendar sweep ' . $date . ': not started after boundary'); + } + + $day = $day->modify('+1 day'); +} + +$invalidParseCases = [ + ['blank date', '', '12:00', $utc], + ['blank time', '20.09.2026', '', $utc], + ['wrong separator', '20-09-2026', '12:00', $utc], + ['un-padded date', '20.9.2026', '12:00', $utc], + ['two-digit year', '20.09.26', '12:00', $utc], + ['zero day', '00.09.2026', '12:00', $utc], + ['day too large', '32.09.2026', '12:00', $utc], + ['zero month', '20.00.2026', '12:00', $utc], + ['month too large', '20.13.2026', '12:00', $utc], + ['invalid month length', '31.04.2026', '12:00', $utc], + ['invalid non-leap day', '29.02.2025', '12:00', $utc], + ['invalid non-leap century', '29.02.2100', '12:00', $utc], + ['un-padded time', '20.09.2026', '9:00', $utc], + ['wrong time separator', '20.09.2026', '09.00', $utc], + ['hour too large', '20.09.2026', '24:00', $utc], + ['minute too large', '20.09.2026', '23:60', $utc], + ['unexpected seconds', '20.09.2026', '12:00:00', $utc], + ['non-numeric time', '20.09.2026', 'noon', $utc], + ['NUL byte in date', "20.\0.09.2026", '12:00', $utc], + ['NUL byte in time', '20.09.2026', "12:\000", $utc], + ['DST skipped local time', '29.03.2026', '01:30', $lisbon], +]; + +foreach ($invalidParseCases as [$label, $date, $time, $timezone]) { + $actual = TravianDateTime::getServerStartTimestamp($date, $time, $timezone); + if ($actual !== null) { + fail_server_start_test( + $label . ': expected invalid configuration, got timestamp ' . $actual + ); + } + + if (TravianDateTime::hasServerStarted($date, $time, PHP_INT_MAX, $timezone)) { + fail_server_start_test($label . ': invalid configuration must fail closed'); + } +} + +// Verify that omitting the timezone uses PHP's configured server timezone at +// the exact decision boundary, rather than silently falling back to UTC. +$originalTimezone = date_default_timezone_get(); +date_default_timezone_set('Asia/Kathmandu'); +try { + $defaultTimezoneStart = test_timestamp('2026-01-01 00:00:00', $kathmandu); + if (TravianDateTime::hasServerStarted('01.01.2026', '00:00', $defaultTimezoneStart - 1)) { + fail_server_start_test('default timezone: started one second early'); + } + if (!TravianDateTime::hasServerStarted('01.01.2026', '00:00', $defaultTimezoneStart)) { + fail_server_start_test('default timezone: not started at exact boundary'); + } +} finally { + date_default_timezone_set($originalTimezone); +} + +fwrite(STDOUT, "Server start time regression tests passed.\n");