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' ]); + } +}