From c53ddbc60e1e1b9476fda1d227cc5882e5642d5c Mon Sep 17 00:00:00 2001 From: bahman Date: Sun, 31 May 2026 10:27:20 +0330 Subject: [PATCH 1/7] fix(deck): add min and max date limits to DueDateSelector Signed-off-by: Bahman Jafarzadeh --- src/components/card/DueDateSelector.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/card/DueDateSelector.vue b/src/components/card/DueDateSelector.vue index b9f2669d77..51004141ca 100644 --- a/src/components/card/DueDateSelector.vue +++ b/src/components/card/DueDateSelector.vue @@ -12,7 +12,8 @@ v-model="duedate" :placeholder="t('deck', 'Set a due date')" :hide-label="true" - type="datetime-local" /> + type="datetime-local" + :max="new Date('9999-12-31T23:59:59')" /> Date: Sun, 31 May 2026 10:29:34 +0330 Subject: [PATCH 2/7] feat(validators): add date validation method Signed-off-by: Bahman Jafarzadeh --- lib/Validators/BaseValidator.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index 79e285ba60..6cc4e42165 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -107,6 +107,16 @@ private function not_empty($value): bool { return !empty($value); } + /** + * @param $value + * @return bool + */ + private function date(string $value): bool { + $date = \DateTime::createFromFormat('Y-m-d\TH:i:s.v\Z', $value) + ?: \DateTime::createFromFormat(\DateTime::ATOM, $value); + return $date !== false; + } + /** * @throws Exception */ From 87fedeab8bfb3bdcd1fb85773398b0a149ebc46e Mon Sep 17 00:00:00 2001 From: Bahman Jafarzadeh Date: Sun, 31 May 2026 10:29:56 +0330 Subject: [PATCH 3/7] feat(card): add duedate validation rule Signed-off-by: Bahman Jafarzadeh --- lib/Validators/CardServiceValidator.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/Validators/CardServiceValidator.php b/lib/Validators/CardServiceValidator.php index ddbcf8738c..bc392c76e3 100644 --- a/lib/Validators/CardServiceValidator.php +++ b/lib/Validators/CardServiceValidator.php @@ -21,6 +21,7 @@ public function rules() { 'type' => ['not_empty', 'not_null', 'not_false', 'max:64'], 'order' => ['numeric'], 'owner' => ['not_empty', 'not_null', 'not_false', 'max:64'], + 'duedate' => ['date'], ]; } } From 9be1702313cca79cabc0462bef6de66febaa07b3 Mon Sep 17 00:00:00 2001 From: Bahman Jafarzadeh Date: Sun, 31 May 2026 10:33:50 +0330 Subject: [PATCH 4/7] fix(card): include duedate in card update validation Signed-off-by: Bahman Jafarzadeh --- lib/Service/CardService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index f5995874f4..b9ebe8e75c 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -234,7 +234,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): Card { - $this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order')); + $this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order', 'duedate')); $this->permissionService->checkPermission($this->cardMapper, $id, Acl::PERMISSION_EDIT, allowDeletedCard: true); $this->permissionService->checkPermission($this->stackMapper, $stackId, Acl::PERMISSION_EDIT); From 9e5769f0566ecbbe3d8d4d4f8f47776a297d324c Mon Sep 17 00:00:00 2001 From: bahman026 Date: Mon, 7 Sep 2026 10:46:12 +0330 Subject: [PATCH 5/7] fix(card): reject dates that cannot be stored again The 'date' rule only accepted two of the formats that the API actually receives, and relied on \DateTime to reject the rest. \DateTime silently misreads out of range input instead of failing: '12345-01-01' is parsed as 2005-01-01 12:34. Such a value is written to the DATETIME column but can no longer be read back, which leaves the card permanently broken. Match the value against an explicit list of accepted formats and reject overflowing components (month 13, February 30), which createFromFormat() only reports through its warnings. An empty value stays valid so optional dates can be unset. Validate startdate the same way as duedate, and check both of them on create() as well - previously only update() checked duedate, so an invalid date could still enter the database through card creation. On the frontend, bound the native datetime-local input on both ends instead of only the upper one, so a year like 20250 can no longer be typed. Signed-off-by: bahman026 --- lib/Service/CardService.php | 4 +- lib/Validators/BaseValidator.php | 49 +++++++++++- lib/Validators/CardServiceValidator.php | 1 + src/components/card/DueDateSelector.vue | 17 ++++- .../Validators/CardServiceValidatorTest.php | 76 +++++++++++++++++++ 5 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 tests/unit/Validators/CardServiceValidatorTest.php diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index e9e36a68e7..1011c9ba5b 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -188,7 +188,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): Card { - $this->cardServiceValidator->check(compact('title', 'stackId', 'type', 'order', 'owner')); + $this->cardServiceValidator->check(compact('title', 'stackId', 'type', 'order', 'owner', 'duedate', 'startdate')); $this->permissionService->checkPermission($this->stackMapper, $stackId, Acl::PERMISSION_EDIT); if ($this->boardService->isArchived($this->stackMapper, $stackId)) { @@ -247,7 +247,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, ?string $color = null): Card { - $this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order', 'duedate')); + $this->cardServiceValidator->check(compact('id', 'title', 'stackId', 'type', 'owner', 'order', 'duedate', 'startdate')); $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 c9bc013cd0..a9bc332a2d 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -13,6 +13,18 @@ use OCA\Deck\BadRequestException; abstract class BaseValidator { + /** + * Date formats accepted by the 'date' rule, covering the ISO-8601 output of + * the web frontend as well as the formats documented in docs/API.md. + */ + private const DATE_FORMATS = [ + 'Y-m-d\TH:i:s.v\Z', + 'Y-m-d\TH:i:s.u\Z', + \DateTimeInterface::ATOM, + 'Y-m-d\TH:i:s', + 'Y-m-d H:i:s', + 'Y-m-d', + ]; /** * @return array @@ -116,13 +128,42 @@ private function not_empty($value): bool { } /** + * Check that a value is a date that can be stored and read back again. + * + * The value is matched against an explicit list of accepted formats, + * because \DateTime silently misreads out of range input instead of + * rejecting it: '12345-01-01' for example is parsed as 2005-01-01 12:34. + * Such a value is written to the database but can no longer be parsed + * when it is read again, which leaves the card permanently broken. + * + * An empty value is considered valid so that optional dates can be unset. + * * @param $value * @return bool */ - private function date(string $value): bool { - $date = \DateTime::createFromFormat('Y-m-d\TH:i:s.v\Z', $value) - ?: \DateTime::createFromFormat(\DateTime::ATOM, $value); - return $date !== false; + private function date($value): bool { + if ($value === null || $value === '') { + return true; + } + + if (!is_string($value)) { + return false; + } + + foreach (self::DATE_FORMATS as $format) { + if (\DateTimeImmutable::createFromFormat($format, $value) === false) { + continue; + } + + // createFromFormat() accepts overflowing values such as month 13 + // and only reports them through the warnings. + $errors = \DateTimeImmutable::getLastErrors(); + if ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0)) { + return true; + } + } + + return false; } /** diff --git a/lib/Validators/CardServiceValidator.php b/lib/Validators/CardServiceValidator.php index bc392c76e3..fd35bc26a6 100644 --- a/lib/Validators/CardServiceValidator.php +++ b/lib/Validators/CardServiceValidator.php @@ -22,6 +22,7 @@ public function rules() { 'order' => ['numeric'], 'owner' => ['not_empty', 'not_null', 'not_false', 'max:64'], 'duedate' => ['date'], + 'startdate' => ['date'], ]; } } diff --git a/src/components/card/DueDateSelector.vue b/src/components/card/DueDateSelector.vue index e97ee2b177..b9b9c27280 100644 --- a/src/components/card/DueDateSelector.vue +++ b/src/components/card/DueDateSelector.vue @@ -13,7 +13,8 @@ :placeholder="t('deck', 'Set a due date')" :hide-label="true" type="datetime-local" - :max="new Date('9999-12-31T23:59:59')" /> + :min="minDate" + :max="maxDate" /> assertPass([ 'title' => 'Short title' ]); + $this->assertPass([ 'title' => str_repeat('A', 255) ]); + $this->assertFail([ 'title' => str_repeat('A', 256) ]); + $this->assertFail([ 'title' => '' ]); + $this->assertFail([ 'title' => null ]); + } + + public function testId() { + $this->assertPass([ 'id' => 1234 ]); + $this->assertPass([ 'id' => '1234' ]); + $this->assertFail([ 'id' => 'a1234' ]); + $this->assertFail([ 'id' => '' ]); + $this->assertFail([ 'id' => null ]); + } + + public function testDuedateAcceptsSupportedFormats() { + // ISO-8601 as sent by the web frontend + $this->assertPass([ 'duedate' => '2019-12-24T19:29:30.000Z' ]); + $this->assertPass([ 'duedate' => '2019-12-24T19:29:30.123456Z' ]); + // ISO-8601 as documented in docs/API.md + $this->assertPass([ 'duedate' => '2019-12-24T19:29:30+00:00' ]); + $this->assertPass([ 'duedate' => '2019-12-24T19:29:30Z' ]); + // Formats used by API consumers and the integration tests + $this->assertPass([ 'duedate' => '2020-12-12 13:37:00' ]); + $this->assertPass([ 'duedate' => '2020-12-12' ]); + $this->assertPass([ 'duedate' => '3000-12-12' ]); + // A leap day is a valid date + $this->assertPass([ 'duedate' => '2020-02-29T00:00:00.000Z' ]); + } + + public function testDuedateCanBeUnset() { + $this->assertPass([ 'duedate' => null ]); + $this->assertPass([ 'duedate' => '' ]); + } + + public function testDuedateRejectsOutOfRangeYears() { + $this->assertFail([ 'duedate' => '20250-12-09T04:30:00.000Z' ]); + $this->assertFail([ 'duedate' => '20250-12-09 04:30:00' ]); + $this->assertFail([ 'duedate' => '99999-01-01T00:00:00Z' ]); + // \DateTime would silently read this as 2005-01-01 12:34 + $this->assertFail([ 'duedate' => '12345-01-01' ]); + } + + public function testDuedateRejectsInvalidDates() { + $this->assertFail([ 'duedate' => '2025-13-45T99:99:99.000Z' ]); + $this->assertFail([ 'duedate' => '2025-02-30T00:00:00.000Z' ]); + $this->assertFail([ 'duedate' => 'not-a-date' ]); + $this->assertFail([ 'duedate' => '0' ]); + $this->assertFail([ 'duedate' => 1234 ]); + } + + public function testStartdate() { + $this->assertPass([ 'startdate' => '2019-12-24T19:29:30+00:00' ]); + $this->assertPass([ 'startdate' => null ]); + $this->assertFail([ 'startdate' => '20250-12-09T04:30:00.000Z' ]); + } +} From ba0a8a7ea920e4c49a3010299c0f37c56c777aeb Mon Sep 17 00:00:00 2001 From: bahman026 Date: Mon, 7 Sep 2026 10:48:13 +0330 Subject: [PATCH 6/7] fix(card): bound the start date input as well The start date ends up in the same DATETIME column as the due date and is now validated the same way server side, so the picker needs the same range. Without it a year like 20250 is still accepted by the input and only rejected once the request reaches the validator. Move the two bounds into a shared helper rather than repeating them in both selectors. Signed-off-by: bahman026 --- src/components/card/DueDateSelector.vue | 7 +------ src/components/card/StartDateSelector.vue | 13 ++++++++++++- src/helpers/dateRange.js | 10 ++++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) create mode 100644 src/helpers/dateRange.js diff --git a/src/components/card/DueDateSelector.vue b/src/components/card/DueDateSelector.vue index 802d50ff91..29037f5a58 100644 --- a/src/components/card/DueDateSelector.vue +++ b/src/components/card/DueDateSelector.vue @@ -110,12 +110,7 @@ import CheckIcon from 'vue-material-design-icons/Check.vue' import ClearIcon from 'vue-material-design-icons/Close.vue' import CardDetailEntry from './CardDetailEntry.vue' import { useCardStore } from '../../stores/card.js' - -// The database stores dates as DATETIME, so anything outside of this range -// cannot be read back again. Typing a year like 20250 into the native input -// would otherwise leave the card unreadable. -const MIN_DATE = new Date('1000-01-01T00:00:00') -const MAX_DATE = new Date('9999-12-31T23:59:59') +import { MIN_DATE, MAX_DATE } from '../../helpers/dateRange.js' export default defineComponent({ name: 'DueDateSelector', diff --git a/src/components/card/StartDateSelector.vue b/src/components/card/StartDateSelector.vue index 1e632f6e55..cb19ccb1cc 100644 --- a/src/components/card/StartDateSelector.vue +++ b/src/components/card/StartDateSelector.vue @@ -11,7 +11,9 @@ v-model="startdate" :placeholder="t('deck', 'Set a start date')" :hide-label="true" - type="datetime-local" /> + type="datetime-local" + :min="minDate" + :max="maxDate" /> @@ -53,6 +55,7 @@ import readableDate from '../../mixins/readableDate.js' import Plus from 'vue-material-design-icons/Plus.vue' import CalendarStart from 'vue-material-design-icons/CalendarArrowLeft.vue' import CardDetailEntry from './CardDetailEntry.vue' +import { MIN_DATE, MAX_DATE } from '../../helpers/dateRange.js' export default defineComponent({ name: 'StartDateSelector', @@ -78,6 +81,14 @@ export default defineComponent({ }, }, computed: { + minDate() { + return MIN_DATE + }, + + maxDate() { + return MAX_DATE + }, + startdate: { get() { return this.card?.startdate ? new Date(this.card.startdate) : null diff --git a/src/helpers/dateRange.js b/src/helpers/dateRange.js new file mode 100644 index 0000000000..cdf807913e --- /dev/null +++ b/src/helpers/dateRange.js @@ -0,0 +1,10 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +// Card dates are stored in a DATETIME column, so anything outside of this +// range cannot be read back again. Typing a year like 20250 into a native +// date input would otherwise leave the card unreadable. +export const MIN_DATE = new Date('1000-01-01T00:00:00') +export const MAX_DATE = new Date('9999-12-31T23:59:59') From 02b38d0792809c0cb5bc77f06f7a4776066dbc96 Mon Sep 17 00:00:00 2001 From: bahman026 Date: Mon, 7 Sep 2026 10:49:32 +0330 Subject: [PATCH 7/7] fix(validators): check the year against the column range createFromFormat() does not bound 'Y' to four digits - it consumes as many digits as it finds, so '12345-01-01' and '20250-12-09 04:30:00' parse cleanly and produce no warnings, and the format list alone let them through. Compare the parsed year against the range the DATETIME column can hold, which is also the range the date pickers now offer. Signed-off-by: bahman026 --- lib/Validators/BaseValidator.php | 29 ++++++++++++++----- .../Validators/CardServiceValidatorTest.php | 7 +++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/Validators/BaseValidator.php b/lib/Validators/BaseValidator.php index a9bc332a2d..3b8a04b379 100644 --- a/lib/Validators/BaseValidator.php +++ b/lib/Validators/BaseValidator.php @@ -26,6 +26,12 @@ abstract class BaseValidator { 'Y-m-d', ]; + /** + * Years that fit into the DATETIME columns the dates are stored in. + */ + private const MIN_YEAR = 1000; + private const MAX_YEAR = 9999; + /** * @return array */ @@ -130,11 +136,12 @@ private function not_empty($value): bool { /** * Check that a value is a date that can be stored and read back again. * - * The value is matched against an explicit list of accepted formats, - * because \DateTime silently misreads out of range input instead of - * rejecting it: '12345-01-01' for example is parsed as 2005-01-01 12:34. - * Such a value is written to the database but can no longer be parsed - * when it is read again, which leaves the card permanently broken. + * \DateTime accepts far more than the DATETIME column can hold, and + * silently misreads the rest instead of rejecting it: '12345-01-01' is + * parsed as 2005-01-01 12:34. Such a value is written to the database but + * can no longer be parsed when it is read again, which leaves the card + * permanently broken. The value is therefore matched against an explicit + * list of accepted formats and its year checked against the column range. * * An empty value is considered valid so that optional dates can be unset. * @@ -151,16 +158,22 @@ private function date($value): bool { } foreach (self::DATE_FORMATS as $format) { - if (\DateTimeImmutable::createFromFormat($format, $value) === false) { + $date = \DateTimeImmutable::createFromFormat($format, $value); + if ($date === false) { continue; } // createFromFormat() accepts overflowing values such as month 13 // and only reports them through the warnings. $errors = \DateTimeImmutable::getLastErrors(); - if ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0)) { - return true; + if ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) { + continue; } + + // 'Y' consumes any number of digits, so a year like 20250 parses + // without a complaint even though it no longer fits the column. + $year = (int)$date->format('Y'); + return $year >= self::MIN_YEAR && $year <= self::MAX_YEAR; } return false; diff --git a/tests/unit/Validators/CardServiceValidatorTest.php b/tests/unit/Validators/CardServiceValidatorTest.php index 36fee807d5..cd25b52241 100644 --- a/tests/unit/Validators/CardServiceValidatorTest.php +++ b/tests/unit/Validators/CardServiceValidatorTest.php @@ -60,6 +60,13 @@ public function testDuedateRejectsOutOfRangeYears() { $this->assertFail([ 'duedate' => '12345-01-01' ]); } + public function testDuedateYearBounds() { + $this->assertPass([ 'duedate' => '1000-01-01' ]); + $this->assertPass([ 'duedate' => '9999-12-31T23:59:59' ]); + $this->assertFail([ 'duedate' => '0999-12-31' ]); + $this->assertFail([ 'duedate' => '10000-01-01' ]); + } + public function testDuedateRejectsInvalidDates() { $this->assertFail([ 'duedate' => '2025-13-45T99:99:99.000Z' ]); $this->assertFail([ 'duedate' => '2025-02-30T00:00:00.000Z' ]);