Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/Service/CardService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
Expand Down
64 changes: 64 additions & 0 deletions lib/Validators/BaseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down
2 changes: 2 additions & 0 deletions lib/Validators/CardServiceValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
];
}
}
13 changes: 12 additions & 1 deletion src/components/card/DueDateSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
<NcActions
v-if="canEdit"
:menuName="!duedate ? t('deck', 'Add due date') : null"
Expand Down Expand Up @@ -115,6 +117,7 @@ import CheckIcon from 'vue-material-design-icons/Check.vue'
import ClearIcon from 'vue-material-design-icons/Close.vue'
import Plus from 'vue-material-design-icons/Plus.vue'
import CardDetailEntry from './CardDetailEntry.vue'
import { MAX_DATE, MIN_DATE } from '../../helpers/dateRange.js'
import readableDate from '../../mixins/readableDate.js'
import { useCardStore } from '../../stores/card.js'

Expand Down Expand Up @@ -174,6 +177,14 @@ export default defineComponent({
},

computed: {
minDate() {
return MIN_DATE
},

maxDate() {
return MAX_DATE
},

duedate: {
get() {
return this.card?.duedate ? new Date(this.card.duedate) : null
Expand Down
13 changes: 12 additions & 1 deletion src/components/card/StartDateSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
v-model="startdate"
:placeholder="t('deck', 'Set a start date')"
:hideLabel="true"
type="datetime-local" />
type="datetime-local"
:min="minDate"
:max="maxDate" />
<NcActions
v-if="canEdit"
:forceName="!startdate"
Expand Down Expand Up @@ -58,6 +60,7 @@ import { defineComponent } from 'vue'
import CalendarStart from 'vue-material-design-icons/CalendarArrowLeft.vue'
import Plus from 'vue-material-design-icons/Plus.vue'
import CardDetailEntry from './CardDetailEntry.vue'
import { MAX_DATE, MIN_DATE } from '../../helpers/dateRange.js'
import readableDate from '../../mixins/readableDate.js'

export default defineComponent({
Expand Down Expand Up @@ -89,6 +92,14 @@ export default defineComponent({

emits: ['input'],
computed: {
minDate() {
return MIN_DATE
},

maxDate() {
return MAX_DATE
},

startdate: {
get() {
return this.card?.startdate ? new Date(this.card.startdate) : null
Expand Down
10 changes: 10 additions & 0 deletions src/helpers/dateRange.js
Original file line number Diff line number Diff line change
@@ -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')
83 changes: 83 additions & 0 deletions tests/unit/Validators/CardServiceValidatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

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 testTitle() {
$this->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' ]);
}
}