diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php
index 695a249d68..5292fe5a71 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', 'duedate', 'startdate'));
$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', '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 8b1350442e..3b8a04b379 100644
--- a/lib/Validators/BaseValidator.php
+++ b/lib/Validators/BaseValidator.php
@@ -13,6 +13,24 @@
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',
+ ];
+
+ /**
+ * Years that fit into the DATETIME columns the dates are stored in.
+ */
+ private const MIN_YEAR = 1000;
+ private const MAX_YEAR = 9999;
/**
* @return array
@@ -115,6 +133,52 @@ private function not_empty($value): bool {
return !empty($value);
}
+ /**
+ * Check that a value is a date that can be stored and read back again.
+ *
+ * \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.
+ *
+ * @param $value
+ * @return bool
+ */
+ private function date($value): bool {
+ if ($value === null || $value === '') {
+ return true;
+ }
+
+ if (!is_string($value)) {
+ return false;
+ }
+
+ foreach (self::DATE_FORMATS as $format) {
+ $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)) {
+ 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;
+ }
+
/**
* @throws Exception
*/
diff --git a/lib/Validators/CardServiceValidator.php b/lib/Validators/CardServiceValidator.php
index ddbcf8738c..fd35bc26a6 100644
--- a/lib/Validators/CardServiceValidator.php
+++ b/lib/Validators/CardServiceValidator.php
@@ -21,6 +21,8 @@ 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'],
+ 'startdate' => ['date'],
];
}
}
diff --git a/src/components/card/DueDateSelector.vue b/src/components/card/DueDateSelector.vue
index a531c9de03..2183141c15 100644
--- a/src/components/card/DueDateSelector.vue
+++ b/src/components/card/DueDateSelector.vue
@@ -15,7 +15,9 @@
v-model="duedate"
:placeholder="t('deck', 'Set a due date')"
:hideLabel="true"
- type="datetime-local" />
+ type="datetime-local"
+ :min="minDate"
+ :max="maxDate" />
+ type="datetime-local"
+ :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 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' ]);
+ $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' ]);
+ }
+}