From b67f6b5bf97ab2a16fa3d3bac64c208096edbfd9 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Tue, 15 Sep 2026 11:43:20 +0200 Subject: [PATCH 1/6] enh: validate datetime fields Signed-off-by: Luka Trovic --- lib/Service/CardService.php | 4 +- lib/Validators/BaseValidator.php | 45 +++++++++++++++++++++-- lib/Validators/CardServiceValidator.php | 3 ++ src/components/card/DueDateSelector.vue | 7 +++- src/components/card/StartDateSelector.vue | 7 +++- 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 695a249d68..cd58223d13 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -190,7 +190,7 @@ public function findCalendarEntries(int $boardId): array { * @throws BadrequestException */ public function create(string $title, int $stackId, string $type, int $order, string $owner, string $description = '', $duedate = null, $startdate = null, ?string $color = null, bool $insertAtPosition = false): Card { - $this->cardServiceValidator->check(compact('title', 'stackId', 'type', 'order', 'owner')); + $this->cardServiceValidator->check(compact('title', 'stackId', 'type', 'order', 'owner', 'startdate', 'duedate')); $this->permissionService->checkPermission($this->stackMapper, $stackId, Acl::PERMISSION_EDIT); if ($this->boardService->isArchived($this->stackMapper, $stackId)) { @@ -263,7 +263,7 @@ public function delete(int $id): Card { * @throws BadRequestException */ public function update(int $id, string $title, int $stackId, string $type, string $owner, string $description = '', int $order = 0, ?string $duedate = null, ?int $deletedAt = null, ?bool $archived = null, ?OptionalNullableValue $done = null, ?string $startdate = null, ?OptionalNullableValue $color = null): Card { - $this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order')); + $this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order', 'startdate', 'duedate', 'done')); $this->permissionService->checkPermission($this->cardMapper, $id, Acl::PERMISSION_EDIT, allowDeletedCard: true); $this->permissionService->checkPermission($this->stackMapper, $stackId, Acl::PERMISSION_EDIT); diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index 8b1350442e..23fe87b458 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -11,6 +11,7 @@ use Exception; use OCA\Deck\BadRequestException; +use OCA\Deck\Model\OptionalNullableValue; abstract class BaseValidator { @@ -31,9 +32,22 @@ private function validate($data) { foreach ($data as $field => $value) { $field_rule = $rules[$field]; + $isOptional = is_array($field_rule) && in_array('optional', $field_rule, true); + + if ($isOptional) { + if ($value instanceof OptionalNullableValue) { + $value = $value->getValue(); + } + if ($value === null) { + continue; + } + } if (is_array($field_rule)) { foreach ($field_rule as $rule) { + if ($rule === 'optional') { + continue; + } // The format for specifying validation rules and parameters follows an // easy {rule}:{parameters} formatting convention. For instance the // rule "Max:3" states that the value may only be three letters. @@ -41,12 +55,12 @@ private function validate($data) { [$rule, $parameter] = explode(':', $rule, 2); if (!$this->{$rule}($value, $parameter)) { throw new BadRequestException( - $this->getErrorMessage($rule, $field, $parameter)); + $this->getErrorMessage($rule, $field, $parameter, $isOptional)); } } else { if (!$this->{$rule}($value)) { throw new BadRequestException( - $field . ' must be provided and must be ' . str_replace('_', ' ', $rule)); + $this->getErrorMessage($rule, $field, null, $isOptional)); } } } @@ -135,6 +149,27 @@ private function min($value, $limit): bool { return $this->getSize($value) >= $limit; } + /** + * @throws Exception + */ + private function datetime($value): bool { + if (!is_string($value)) { + return false; + } + + try { + $datetime = new \DateTime($value); + } catch (\Exception $e) { + return false; + } + + if ($datetime->format('Y') > 9999) { + return false; + } + + return true; + } + /** * Get the size of an attribute. * @@ -159,13 +194,17 @@ protected function getSize($value): int { * @param $parameter * @return string */ - protected function getErrorMessage(string $rule, $field, $parameter = null): string { + protected function getErrorMessage(string $rule, $field, $parameter = null, $isOptional = false): string { if (in_array($rule, ['max', 'min'], true)) { return $rule === 'max' ? $field . ' cannot be longer than ' . $parameter . ' characters ' : $field . ' must be at least ' . $parameter . ' characters long '; } + if ($isOptional) { + return $field . ' must be ' . str_replace('_', ' ', $rule); + } + return $field . ' must be provided and must be ' . str_replace('_', ' ', $rule); } } diff --git a/lib/Validators/CardServiceValidator.php b/lib/Validators/CardServiceValidator.php index ddbcf8738c..faa0af366d 100644 --- a/lib/Validators/CardServiceValidator.php +++ b/lib/Validators/CardServiceValidator.php @@ -21,6 +21,9 @@ public function rules() { 'type' => ['not_empty', 'not_null', 'not_false', 'max:64'], 'order' => ['numeric'], 'owner' => ['not_empty', 'not_null', 'not_false', 'max:64'], + 'startdate' => ['optional', 'datetime'], + 'duedate' => ['optional', 'datetime'], + 'done' => ['optional', 'datetime'], ]; } } diff --git a/src/components/card/DueDateSelector.vue b/src/components/card/DueDateSelector.vue index a531c9de03..0711559ff5 100644 --- a/src/components/card/DueDateSelector.vue +++ b/src/components/card/DueDateSelector.vue @@ -180,7 +180,12 @@ export default defineComponent({ }, set(val) { - this.$emit('input', val ? new Date(val) : null) + // Prevent setting a date with a year greater than 9999, as this can cause issues with some date libraries and databases. + const date = val ? new Date(val) : null + if (date && date.getFullYear() > 9999) { + return + } + this.$emit('input', date) }, }, diff --git a/src/components/card/StartDateSelector.vue b/src/components/card/StartDateSelector.vue index 3cacc97057..42c709f432 100644 --- a/src/components/card/StartDateSelector.vue +++ b/src/components/card/StartDateSelector.vue @@ -95,7 +95,12 @@ export default defineComponent({ }, set(val) { - this.$emit('input', val ? new Date(val) : null) + // Prevent setting a date with a year greater than 9999, as this can cause issues with some date libraries and databases. + const date = val ? new Date(val) : null + if (date && date.getFullYear() > 9999) { + return + } + this.$emit('input', date) }, }, }, From 21160cd3d6f7cbd2fd5236007d9f4dfb14f18b49 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Fri, 18 Sep 2026 18:44:50 +0200 Subject: [PATCH 2/6] enh: improve datetime validation Signed-off-by: Luka Trovic --- appinfo/info.xml | 1 + lib/Command/FixCardDate.php | 130 ++++++++++++++++++ lib/Service/CardService.php | 4 +- lib/Validators/BaseValidator.php | 30 ++-- src/components/card/DueDateSelector.vue | 6 +- src/components/card/StartDateSelector.vue | 6 +- src/mixins/readableDate.js | 9 ++ .../Validators/CardServiceValidatorTest.php | 61 ++++++++ 8 files changed, 226 insertions(+), 21 deletions(-) create mode 100644 lib/Command/FixCardDate.php create mode 100644 tests/unit/Validators/CardServiceValidatorTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 8f696b737b..dd1b8aac91 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -63,6 +63,7 @@ OCA\Deck\Command\BoardImport OCA\Deck\Command\TransferOwnership OCA\Deck\Command\CalendarToggle + OCA\Deck\Command\FixCardDate diff --git a/lib/Command/FixCardDate.php b/lib/Command/FixCardDate.php new file mode 100644 index 0000000000..d69fd99775 --- /dev/null +++ b/lib/Command/FixCardDate.php @@ -0,0 +1,130 @@ +setName('deck:fix-card-date') + ->setDescription('Fixes the date fields of cards. In some DBs, the date fields may have been stored incorrectly, causing issues with date handling. This command will attempt to fix those dates.') + ->addOption( + 'default-year', + null, + InputOption::VALUE_OPTIONAL, + 'Default year to use for cards with invalid dates. If not provided, the large year number will be trimmed to first 4 digits.', + ) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_OPTIONAL, + 'Dry run, do not make any changes.', + ) + ; + } + + /** + * @throws Exception + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $isDryRun = (bool)$input->getOption('dry-run'); + $defaultYear = $input->getOption('default-year'); + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from('deck_cards') + ->where($qb->expr()->orX( + $qb->expr()->gt('duedate', $qb->createNamedParameter('9999-01-01')), + $qb->expr()->gt('startdate', $qb->createNamedParameter('9999-01-01')), + $qb->expr()->gt('done', $qb->createNamedParameter('9999-01-01')), + )); + $cards = $qb->executeQuery()->fetchAllAssociative(); + + if (empty($cards)) { + $output->writeln('No cards with invalid dates found.'); + return 0; + } + + foreach ($cards as $card) { + $cardId = $card['id']; + $duedate = $card['duedate']; + $startdate = $card['startdate']; + $done = $card['done']; + + $output->writeln("Card ID: $cardId"); + + if ($duedate && $this->isInvalidDate($duedate)) { + $output->writeln(" Original Due Date: $duedate"); + $duedate = $this->fixDate($duedate, $defaultYear); + + if (!$isDryRun) { + $this->updateCardDate($cardId, 'duedate', $duedate); + } + $output->writeln(" Fixed Due Date: $duedate"); + } + + if ($startdate && $this->isInvalidDate($startdate)) { + $output->writeln(" Original Start Date: $startdate"); + $startdate = $this->fixDate($startdate, $defaultYear); + + if (!$isDryRun) { + $this->updateCardDate($cardId, 'startdate', $startdate); + } + $output->writeln(" Fixed Start Date: $startdate"); + } + + if ($done && $this->isInvalidDate($done)) { + $output->writeln(" Original Done Date: $done"); + $done = $this->fixDate($done, $defaultYear); + + if (!$isDryRun) { + $this->updateCardDate($cardId, 'done', $done); + } + $output->writeln(" Fixed Done Date: $done"); + } + } + + return 0; + } + + protected function isInvalidDate(string $date): bool { + $parts = explode('-', $date); + $year = $parts[0]; + return $year > 9999; + } + + protected function fixDate(string $date, ?string $defaultYear): string { + $parts = explode('-', $date); + $year = $parts[0]; + $fixedYear = $defaultYear; + if (!$defaultYear) { + $fixedYear = substr($year, 0, 4); + } + return str_replace($year . '-', $fixedYear . '-', $date); + } + + protected function updateCardDate(int $cardId, string $field, string $newDate): void { + $qb = $this->db->getQueryBuilder(); + $qb->update('deck_cards') + ->set($field, $qb->createNamedParameter($newDate)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($cardId))); + $qb->executeStatement(); + } +} diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index cd58223d13..2f158d966f 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -203,8 +203,8 @@ public function create(string $title, int $stackId, string $type, int $order, st $card->setOrder($order); $card->setOwner($owner); $card->setDescription($description); - $card->setDuedate($duedate); - $card->setStartdate($startdate); + $card->setDuedate($duedate ? new \DateTime($duedate) : null); + $card->setStartdate($startdate ? new \DateTime($startdate) : null); $card->setColor($color); if (!$insertAtPosition) { diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index 23fe87b458..1411fdc15b 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -152,22 +152,30 @@ private function min($value, $limit): bool { /** * @throws Exception */ - private function datetime($value): bool { - if (!is_string($value)) { + private function datetime(mixed $value): bool { + if (!is_string($value) || $value === '') { return false; } - try { - $datetime = new \DateTime($value); - } catch (\Exception $e) { - return false; - } - - if ($datetime->format('Y') > 9999) { - return false; + $allowedFormats = [ + 'Y-m-d', + 'Y-m-d H:i:s', + 'Y-m-d\TH:i:sP', + 'Y-m-d\TH:i:s.v\Z', + ]; + + foreach ($allowedFormats as $format) { + $datetime = \DateTime::createFromFormat($format, $value); + if ($datetime && $datetime->format($format) === $value) { + // Check if the year is within the valid range + if ((int)$datetime->format('Y') > 9999) { + return false; + } + return true; + } } - return true; + return false; } /** diff --git a/src/components/card/DueDateSelector.vue b/src/components/card/DueDateSelector.vue index 0711559ff5..4f7dcfa599 100644 --- a/src/components/card/DueDateSelector.vue +++ b/src/components/card/DueDateSelector.vue @@ -180,12 +180,10 @@ export default defineComponent({ }, set(val) { - // Prevent setting a date with a year greater than 9999, as this can cause issues with some date libraries and databases. - const date = val ? new Date(val) : null - if (date && date.getFullYear() > 9999) { + if (!this.isValidDate(val)) { return } - this.$emit('input', date) + this.$emit('input', val ? new Date(val) : null) }, }, diff --git a/src/components/card/StartDateSelector.vue b/src/components/card/StartDateSelector.vue index 42c709f432..77e6146aae 100644 --- a/src/components/card/StartDateSelector.vue +++ b/src/components/card/StartDateSelector.vue @@ -95,12 +95,10 @@ export default defineComponent({ }, set(val) { - // Prevent setting a date with a year greater than 9999, as this can cause issues with some date libraries and databases. - const date = val ? new Date(val) : null - if (date && date.getFullYear() > 9999) { + if (!this.isValidDate(val)) { return } - this.$emit('input', date) + this.$emit('input', val ? new Date(val) : null) }, }, }, diff --git a/src/mixins/readableDate.js b/src/mixins/readableDate.js index df30fa8c4b..1f7eb7940f 100644 --- a/src/mixins/readableDate.js +++ b/src/mixins/readableDate.js @@ -5,6 +5,8 @@ import moment from '@nextcloud/moment' +const maxYear = 9999 + export default { computed: { formatReadableDate() { @@ -13,4 +15,11 @@ export default { } }, }, + methods: { + isValidDate(val) { + // Prevent setting a date with a year greater than 9999, as this can cause issues with some date libraries and databases. + const date = val ? new Date(val) : null + return !(date && date.getFullYear() > maxYear) + }, + }, } diff --git a/tests/unit/Validators/CardServiceValidatorTest.php b/tests/unit/Validators/CardServiceValidatorTest.php new file mode 100644 index 0000000000..c8266d25f8 --- /dev/null +++ b/tests/unit/Validators/CardServiceValidatorTest.php @@ -0,0 +1,61 @@ +. + */ + +namespace OCA\Deck\Validators; + +use OCA\Deck\Tests\unit\Validators\ValidatorTestBase; + +class CardServiceValidatorTest extends ValidatorTestBase { + public function setUp(): void { + parent::setUpValidatorTest(CardServiceValidator::class); + } + + public function testInvalidDueDateIsRejected(): void { + $this->assertFail([ + 'duedate' => '', + ]); + $this->assertFail([ + 'duedate' => 'not a date', + ]); + $this->assertFail([ + 'duedate' => '99999-01-01 00:00:00', + ]); + $this->assertFail([ + 'duedate' => '+1 day', + ]); + } + + public function testValidDueDateIsAccepted(): void { + $this->assertPass([ + 'duedate' => '2026-01-01T01:00:00.000Z', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01 00:00:00', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T00:00:00+00:00', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01', + ]); + } +} From 3d9ec54084e319b411c69d55c3a47600d7317c4f Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Tue, 29 Sep 2026 09:09:40 +0200 Subject: [PATCH 3/6] enh: allow empty string in datetime validation Signed-off-by: Luka Trovic --- lib/Validators/BaseValidator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index 1411fdc15b..2925bcdb00 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -38,7 +38,7 @@ private function validate($data) { if ($value instanceof OptionalNullableValue) { $value = $value->getValue(); } - if ($value === null) { + if ($value === null || $value === '') { continue; } } From e74c757a07ccd3b6c44336a3aab3424a69949a85 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Thu, 1 Oct 2026 11:57:56 +0200 Subject: [PATCH 4/6] fix: unit test issue Signed-off-by: Luka Trovic --- tests/unit/Validators/CardServiceValidatorTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/unit/Validators/CardServiceValidatorTest.php b/tests/unit/Validators/CardServiceValidatorTest.php index c8266d25f8..451167cdb6 100644 --- a/tests/unit/Validators/CardServiceValidatorTest.php +++ b/tests/unit/Validators/CardServiceValidatorTest.php @@ -30,9 +30,6 @@ public function setUp(): void { } public function testInvalidDueDateIsRejected(): void { - $this->assertFail([ - 'duedate' => '', - ]); $this->assertFail([ 'duedate' => 'not a date', ]); From a9716763381b7e005e3e14a143d344c7a1e427af Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Thu, 8 Oct 2026 14:34:39 +0200 Subject: [PATCH 5/6] fix: resolve comments Signed-off-by: Luka Trovic --- lib/Command/FixCardDate.php | 37 ++++++++++++++++++++++++++------ lib/Validators/BaseValidator.php | 4 ---- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/Command/FixCardDate.php b/lib/Command/FixCardDate.php index d69fd99775..031413598f 100644 --- a/lib/Command/FixCardDate.php +++ b/lib/Command/FixCardDate.php @@ -34,7 +34,7 @@ protected function configure() { ->addOption( 'dry-run', null, - InputOption::VALUE_OPTIONAL, + InputOption::VALUE_NONE, 'Dry run, do not make any changes.', ) ; @@ -47,13 +47,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int $isDryRun = (bool)$input->getOption('dry-run'); $defaultYear = $input->getOption('default-year'); + // Validate default year if provided + if ($defaultYear !== null && !preg_match('/^\d{4}$/', $defaultYear)) { + $output->writeln('Invalid default year provided. It must be a 4-digit year.'); + return 1; + } + + // Fetch cards with date values and validate invalid years in PHP for DB portability. $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from('deck_cards') ->where($qb->expr()->orX( - $qb->expr()->gt('duedate', $qb->createNamedParameter('9999-01-01')), - $qb->expr()->gt('startdate', $qb->createNamedParameter('9999-01-01')), - $qb->expr()->gt('done', $qb->createNamedParameter('9999-01-01')), + $qb->expr()->isNotNull('duedate'), + $qb->expr()->isNotNull('startdate'), + $qb->expr()->isNotNull('done'), )); $cards = $qb->executeQuery()->fetchAllAssociative(); @@ -62,15 +69,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + $hasInvalidCards = false; + foreach ($cards as $card) { $cardId = $card['id']; $duedate = $card['duedate']; $startdate = $card['startdate']; $done = $card['done']; + $hasInvalidDueDate = $duedate && $this->isInvalidDate($duedate); + $hasInvalidStartDate = $startdate && $this->isInvalidDate($startdate); + $hasInvalidDoneDate = $done && $this->isInvalidDate($done); + + if (!$hasInvalidDueDate && !$hasInvalidStartDate && !$hasInvalidDoneDate) { + continue; + } + + $hasInvalidCards = true; $output->writeln("Card ID: $cardId"); - if ($duedate && $this->isInvalidDate($duedate)) { + if ($hasInvalidDueDate) { $output->writeln(" Original Due Date: $duedate"); $duedate = $this->fixDate($duedate, $defaultYear); @@ -80,7 +98,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(" Fixed Due Date: $duedate"); } - if ($startdate && $this->isInvalidDate($startdate)) { + if ($hasInvalidStartDate) { $output->writeln(" Original Start Date: $startdate"); $startdate = $this->fixDate($startdate, $defaultYear); @@ -90,7 +108,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(" Fixed Start Date: $startdate"); } - if ($done && $this->isInvalidDate($done)) { + if ($hasInvalidDoneDate) { $output->writeln(" Original Done Date: $done"); $done = $this->fixDate($done, $defaultYear); @@ -101,6 +119,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } + if (!$hasInvalidCards) { + $output->writeln('No cards with invalid dates found.'); + } + return 0; } @@ -124,6 +146,7 @@ protected function updateCardDate(int $cardId, string $field, string $newDate): $qb = $this->db->getQueryBuilder(); $qb->update('deck_cards') ->set($field, $qb->createNamedParameter($newDate)) + ->set('last_modified', $qb->createNamedParameter(time())) ->where($qb->expr()->eq('id', $qb->createNamedParameter($cardId))); $qb->executeStatement(); } diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index 2925bcdb00..7bb26981fb 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -167,10 +167,6 @@ private function datetime(mixed $value): bool { foreach ($allowedFormats as $format) { $datetime = \DateTime::createFromFormat($format, $value); if ($datetime && $datetime->format($format) === $value) { - // Check if the year is within the valid range - if ((int)$datetime->format('Y') > 9999) { - return false; - } return true; } } From be76dbbcb70ab37c3fabaf0e00ed129f6f3dad91 Mon Sep 17 00:00:00 2001 From: Luka Trovic Date: Wed, 21 Oct 2026 14:28:28 +0200 Subject: [PATCH 6/6] enh: optimize card date fetching in command FixCardDate & enhance date validation to support Java/Android ISO formats Signed-off-by: Luka Trovic --- lib/Command/FixCardDate.php | 11 ++---- lib/Validators/BaseValidator.php | 30 ++++++++++++++++ .../Validators/CardServiceValidatorTest.php | 35 +++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/lib/Command/FixCardDate.php b/lib/Command/FixCardDate.php index 031413598f..bbca1409ac 100644 --- a/lib/Command/FixCardDate.php +++ b/lib/Command/FixCardDate.php @@ -55,23 +55,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Fetch cards with date values and validate invalid years in PHP for DB portability. $qb = $this->db->getQueryBuilder(); - $qb->select('*') + $qb->select('id', 'duedate', 'startdate', 'done') ->from('deck_cards') ->where($qb->expr()->orX( $qb->expr()->isNotNull('duedate'), $qb->expr()->isNotNull('startdate'), $qb->expr()->isNotNull('done'), )); - $cards = $qb->executeQuery()->fetchAllAssociative(); - - if (empty($cards)) { - $output->writeln('No cards with invalid dates found.'); - return 0; - } + $result = $qb->executeQuery(); $hasInvalidCards = false; - foreach ($cards as $card) { + while (($card = $result->fetchAssociative()) !== false) { $cardId = $card['id']; $duedate = $card['duedate']; $startdate = $card['startdate']; diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index 7bb26981fb..74fdc510f7 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -157,10 +157,40 @@ private function datetime(mixed $value): bool { return false; } + // Java/Android commonly send ISO_OFFSET_DATE_TIME and ISO_INSTANT. + if (preg_match('/^(?\d{4}-\d{2}-\d{2})T(?\d{2}):(?\d{2})(?::(?\d{2})(?:\.(?\d{1,9}))?)?(?Z|[+\-]\d{2}:\d{2})$/', $value, $matches)) { + $second = ($matches['second'] ?? '') !== '' ? $matches['second'] : '00'; + $fraction = $matches['fraction'] ?? ''; + + if ($fraction !== '') { + // PHP supports microseconds, so normalize Java nanoseconds to 6 digits. + $fraction = str_pad(substr($fraction, 0, 6), 6, '0'); + } else { + $fraction = '000000'; + } + + $timezone = $matches['timezone'] === 'Z' ? '+00:00' : $matches['timezone']; + $normalizedValue = sprintf( + '%sT%s:%s:%s.%s%s', + $matches['date'], + $matches['hour'], + $matches['minute'], + $second, + $fraction, + $timezone + ); + + $datetime = \DateTime::createFromFormat('Y-m-d\TH:i:s.uP', $normalizedValue); + if ($datetime && $datetime->format('Y-m-d\TH:i:s.uP') === $normalizedValue) { + return true; + } + } + $allowedFormats = [ 'Y-m-d', 'Y-m-d H:i:s', 'Y-m-d\TH:i:sP', + 'Y-m-d\TH:i:s\Z', 'Y-m-d\TH:i:s.v\Z', ]; diff --git a/tests/unit/Validators/CardServiceValidatorTest.php b/tests/unit/Validators/CardServiceValidatorTest.php index 451167cdb6..a43394efd2 100644 --- a/tests/unit/Validators/CardServiceValidatorTest.php +++ b/tests/unit/Validators/CardServiceValidatorTest.php @@ -39,6 +39,15 @@ public function testInvalidDueDateIsRejected(): void { $this->assertFail([ 'duedate' => '+1 day', ]); + $this->assertFail([ + 'duedate' => '99999-01-01T00:00:00Z', + ]); + $this->assertFail([ + 'duedate' => '99999-01-01T00:00Z', + ]); + $this->assertFail([ + 'duedate' => '99999-01-01', + ]); } public function testValidDueDateIsAccepted(): void { @@ -51,6 +60,32 @@ public function testValidDueDateIsAccepted(): void { $this->assertPass([ 'duedate' => '2026-01-01T00:00:00+00:00', ]); + // Java/Android ISO_INSTANT samples + $this->assertPass([ + 'duedate' => '2026-01-01T00:00:00Z', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T00:00:00.123Z', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T00:00:00.123456789Z', + ]); + // Java/Android ISO_OFFSET_DATE_TIME samples + $this->assertPass([ + 'duedate' => '2026-01-01T07:00:00+07:00', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T07:00+07:00', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T00:00Z', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T07:00:00.123+07:00', + ]); + $this->assertPass([ + 'duedate' => '2026-01-01T07:00:00.123456789+07:00', + ]); $this->assertPass([ 'duedate' => '2026-01-01', ]);